Implement production-ready block designer and schema

- Add EnhancedBlockDesigner with Scratch-like block interface - Remove
all legacy designer implementations (React Flow, FreeForm, etc.) - Add
block registry and plugin schema to database - Update experiments table
with visual_design, execution_graph, plugin_dependencies columns and GIN
index - Update drizzle config to use hs_* table filter - Add block
shape/category enums to schema - Update experiment designer route to use
EnhancedBlockDesigner - Add comprehensive documentation for block
designer and implementation tracking
This commit is contained in:
2025-08-05 01:47:53 -04:00
parent b1684a0c69
commit 7cdc1a2340
18 changed files with 2338 additions and 10215 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { X, ArrowLeft } from "lucide-react";
import { Button } from "~/components/ui/button";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import {
FlowDesigner,
type FlowDesign,
type FlowStep,
type StepType,
} from "./FlowDesigner";
interface ExperimentDesignerClientProps {
experiment: {
id: string;
name: string;
description: string;
studyId: string;
study?: {
name: string;
};
};
}
export function ExperimentDesignerClient({
experiment,
}: ExperimentDesignerClientProps) {
const [saveError, setSaveError] = useState<string | null>(null);
const router = useRouter();
// Set breadcrumbs for the designer
useBreadcrumbsEffect([
{ label: "Studies", href: "/studies" },
{
label: experiment.study?.name ?? "Study",
href: `/studies/${experiment.studyId}`,
},
{
label: "Experiments",
href: `/studies/${experiment.studyId}`,
},
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{
label: "Designer",
href: `/experiments/${experiment.id}/designer`,
},
]);
// Fetch the experiment's design data
const { data: experimentSteps, isLoading } =
api.experiments.getSteps.useQuery({
experimentId: experiment.id,
});
const saveDesignMutation = api.experiments.saveDesign.useMutation({
onSuccess: () => {
setSaveError(null);
toast.success("Experiment design saved successfully");
},
onError: (error) => {
setSaveError(error.message);
toast.error(`Failed to save design: ${error.message}`);
},
});
const handleSave = async (design: FlowDesign) => {
try {
await saveDesignMutation.mutateAsync({
experimentId: experiment.id,
steps: design.steps
.filter((step) => step.type !== "start" && step.type !== "end") // Filter out start/end nodes
.map((step) => ({
id: step.id,
type: step.type as "wizard" | "robot" | "parallel" | "conditional",
name: step.name,
order: Math.floor(step.position.x / 250) + 1, // Calculate order from position
parameters: step.parameters,
description: step.description,
duration: step.duration,
actions: step.actions,
expanded: false,
children: [],
parentId: undefined,
})),
version: design.version,
});
} catch (error) {
console.error("Failed to save design:", error);
throw error;
}
};
if (isLoading) {
return (
<div className="flex min-h-[600px] items-center justify-center">
<div className="text-center">
<div className="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2"></div>
<p className="text-muted-foreground">
Loading experiment designer...
</p>
</div>
</div>
);
}
// Convert backend steps to flow format
const convertToFlowSteps = (steps: any[]): FlowStep[] => {
return steps.map((step, index) => ({
id: step.id,
type: step.type as StepType,
name: step.name,
description: step.description ?? undefined,
duration: step.duration ?? undefined,
actions: [], // Actions will be loaded separately if needed
parameters: step.parameters ?? {},
position: {
x: index * 250 + 100,
y: 100,
},
}));
};
const initialDesign: FlowDesign = {
id: experiment.id,
name: experiment.name,
description: experiment.description,
steps: experimentSteps ? convertToFlowSteps(experimentSteps) : [],
version: 1,
lastSaved: new Date(),
};
return (
<div className="bg-background flex h-screen flex-col">
{/* Header */}
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 relative border-b backdrop-blur">
<div className="from-primary/5 to-accent/5 absolute inset-0 bg-gradient-to-r" />
<div className="relative flex items-center justify-between p-6">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/experiments/${experiment.id}`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Experiment
</Button>
<div className="bg-border h-6 w-px" />
<div className="bg-primary flex h-12 w-12 items-center justify-center rounded-xl shadow-lg">
<span className="text-primary-foreground text-xl font-bold">
F
</span>
</div>
<div>
<h1 className="text-2xl font-bold">{experiment.name}</h1>
<p className="text-muted-foreground">
{experiment.description || "Visual Flow Designer"}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<span className="bg-muted rounded-lg px-3 py-1 text-sm">
{experiment.study?.name ?? "Unknown Study"}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/experiments/${experiment.id}`)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Error Display */}
{saveError && (
<div className="border-destructive/50 bg-destructive/10 mx-6 mt-4 rounded-lg border p-4">
<div className="flex items-start">
<div className="flex-1">
<h4 className="text-destructive font-medium">Save Error</h4>
<p className="text-destructive/90 mt-1 text-sm">{saveError}</p>
</div>
</div>
</div>
)}
{/* Flow Designer */}
<div className="flex-1 overflow-hidden">
<FlowDesigner
experimentId={experiment.id}
initialDesign={initialDesign}
onSave={handleSave}
isSaving={saveDesignMutation.isPending}
/>
</div>
</div>
);
}

View File

@@ -1,906 +0,0 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
type Node,
type Edge,
type Connection,
type NodeTypes,
MarkerType,
Panel,
Handle,
Position,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "./flow-theme.css";
import {
Bot,
Users,
Shuffle,
GitBranch,
Play,
Zap,
Eye,
Clock,
Plus,
Save,
Undo,
Redo,
Download,
Upload,
Settings,
Trash2,
Copy,
Edit3,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Separator } from "~/components/ui/separator";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "~/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { toast } from "sonner";
// Types
type StepType =
| "wizard"
| "robot"
| "parallel"
| "conditional"
| "start"
| "end";
type ActionType =
| "speak"
| "move"
| "gesture"
| "look_at"
| "wait"
| "instruction"
| "question"
| "observe";
interface FlowAction {
id: string;
type: ActionType;
name: string;
parameters: Record<string, unknown>;
order: number;
}
interface FlowStep {
id: string;
type: StepType;
name: string;
description?: string;
duration?: number;
actions: FlowAction[];
parameters: Record<string, unknown>;
position: { x: number; y: number };
}
interface FlowDesign {
id: string;
name: string;
description?: string;
steps: FlowStep[];
version: number;
lastSaved: Date;
}
// Step type configurations
const stepTypeConfig = {
start: {
label: "Start",
icon: Play,
color: "#10b981",
bgColor: "bg-green-500",
lightColor: "bg-green-50 border-green-200",
description: "Experiment starting point",
},
wizard: {
label: "Wizard Action",
icon: Users,
color: "#3b82f6",
bgColor: "bg-blue-500",
lightColor: "bg-blue-50 border-blue-200",
description: "Actions performed by human wizard",
},
robot: {
label: "Robot Action",
icon: Bot,
color: "#8b5cf6",
bgColor: "bg-purple-500",
lightColor: "bg-purple-50 border-purple-200",
description: "Actions performed by robot",
},
parallel: {
label: "Parallel Steps",
icon: Shuffle,
color: "#f59e0b",
bgColor: "bg-amber-500",
lightColor: "bg-amber-50 border-amber-200",
description: "Execute multiple steps simultaneously",
},
conditional: {
label: "Conditional Branch",
icon: GitBranch,
color: "#ef4444",
bgColor: "bg-red-500",
lightColor: "bg-red-50 border-red-200",
description: "Branching logic based on conditions",
},
end: {
label: "End",
icon: Play,
color: "#6b7280",
bgColor: "bg-gray-500",
lightColor: "bg-gray-50 border-gray-200",
description: "Experiment end point",
},
};
const actionTypeConfig = {
speak: {
label: "Speak",
icon: Play,
description: "Text-to-speech output",
defaultParams: { text: "Hello, I'm ready to help!" },
},
move: {
label: "Move",
icon: Play,
description: "Move to location or position",
defaultParams: { x: 0, y: 0, speed: 1 },
},
gesture: {
label: "Gesture",
icon: Zap,
description: "Physical gesture or animation",
defaultParams: { gesture: "wave", duration: 2 },
},
look_at: {
label: "Look At",
icon: Eye,
description: "Orient gaze or camera",
defaultParams: { target: "participant" },
},
wait: {
label: "Wait",
icon: Clock,
description: "Pause for specified duration",
defaultParams: { duration: 3 },
},
instruction: {
label: "Instruction",
icon: Settings,
description: "Display instruction for wizard",
defaultParams: { text: "Follow the protocol", allowSkip: true },
},
question: {
label: "Question",
icon: Plus,
description: "Ask participant a question",
defaultParams: { question: "How do you feel?", recordResponse: true },
},
observe: {
label: "Observe",
icon: Eye,
description: "Observe and record behavior",
defaultParams: { target: "participant", duration: 5, notes: "" },
},
};
// Custom Node Components
interface StepNodeProps {
data: {
step: FlowStep;
onEdit: (step: FlowStep) => void;
onDelete: (stepId: string) => void;
onDuplicate: (step: FlowStep) => void;
isSelected: boolean;
};
}
function StepNode({ data }: StepNodeProps) {
const { step, onEdit, onDelete, onDuplicate, isSelected } = data;
const config = stepTypeConfig[step.type];
return (
<div className="relative">
{/* Connection Handles */}
<Handle
type="target"
position={Position.Left}
className="!bg-primary !border-background !h-3 !w-3 !border-2"
id="input"
/>
<Handle
type="source"
position={Position.Right}
className="!bg-primary !border-background !h-3 !w-3 !border-2"
id="output"
/>
<Card
className={`min-w-[200px] border transition-all duration-200 ${
isSelected ? "ring-primary shadow-2xl ring-2" : "hover:shadow-lg"
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className={`${config.bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
>
<config.icon className="h-4 w-4 text-white" />
</div>
<div>
<CardTitle className="text-sm font-medium">
{step.name}
</CardTitle>
<Badge variant="outline" className="text-xs">
{config.label}
</Badge>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(step)}>
<Edit3 className="mr-2 h-4 w-4" />
Edit Step
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDuplicate(step)}>
<Copy className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(step.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Step
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
{step.description && (
<CardContent className="pt-0 pb-2">
<p className="text-muted-foreground text-xs">{step.description}</p>
</CardContent>
)}
{step.actions.length > 0 && (
<CardContent className="pt-0">
<div className="text-muted-foreground text-xs">
{step.actions.length} action{step.actions.length !== 1 ? "s" : ""}
</div>
<div className="mt-1 flex flex-wrap gap-1">
{step.actions.slice(0, 3).map((action) => {
const actionConfig = actionTypeConfig[action.type];
return (
<Badge
key={action.id}
variant="secondary"
className="text-xs"
>
<actionConfig.icon className="mr-1 h-3 w-3" />
{actionConfig.label}
</Badge>
);
})}
{step.actions.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{step.actions.length - 3} more
</Badge>
)}
</div>
</CardContent>
)}
</Card>
</div>
);
}
// Node types configuration
const nodeTypes: NodeTypes = {
stepNode: StepNode,
};
// Main Flow Designer Component
interface FlowDesignerProps {
experimentId: string;
initialDesign: FlowDesign;
onSave?: (design: FlowDesign) => Promise<void>;
isSaving?: boolean;
}
export function FlowDesigner({
experimentId,
initialDesign,
onSave,
isSaving = false,
}: FlowDesignerProps) {
const [design, setDesign] = useState<FlowDesign>(initialDesign);
const [selectedStepId, setSelectedStepId] = useState<string>();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [editingStep, setEditingStep] = useState<FlowStep | null>(null);
// React Flow state
const [reactFlowInstance, setReactFlowInstance] = useState(null);
const selectedStep = useMemo(() => {
return design.steps.find((step) => step.id === selectedStepId);
}, [design.steps, selectedStepId]);
const createStep = useCallback(
(type: StepType, position: { x: number; y: number }): FlowStep => {
const config = stepTypeConfig[type];
const stepNumber = design.steps.length + 1;
return {
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type,
name: `${config.label} ${stepNumber}`,
actions: [],
parameters: {},
position,
};
},
[design.steps.length],
);
const handleStepTypeAdd = useCallback(
(type: StepType) => {
const newPosition = {
x: design.steps.length * 250 + 100,
y: 100,
};
const newStep = createStep(type, newPosition);
setDesign((prev) => ({
...prev,
steps: [...prev.steps, newStep],
}));
setHasUnsavedChanges(true);
setSelectedStepId(newStep.id);
toast.success(`Added ${stepTypeConfig[type].label}`);
},
[createStep, design.steps.length],
);
const handleStepDelete = useCallback(
(stepId: string) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.filter((step) => step.id !== stepId),
}));
setHasUnsavedChanges(true);
if (selectedStepId === stepId) {
setSelectedStepId(undefined);
}
toast.success("Step deleted");
},
[selectedStepId],
);
const handleStepDuplicate = useCallback((step: FlowStep) => {
const newStep: FlowStep = {
...step,
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: `${step.name} (Copy)`,
position: {
x: step.position.x + 250,
y: step.position.y,
},
actions: step.actions.map((action) => ({
...action,
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
})),
};
setDesign((prev) => ({
...prev,
steps: [...prev.steps, newStep],
}));
setHasUnsavedChanges(true);
toast.success("Step duplicated");
}, []);
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
setSelectedStepId(node.id);
},
[],
);
// Convert design steps to React Flow nodes
const nodes: Node[] = useMemo(() => {
return design.steps.map((step) => ({
id: step.id,
type: "stepNode",
position: step.position,
data: {
step,
onEdit: setEditingStep,
onDelete: handleStepDelete,
onDuplicate: handleStepDuplicate,
isSelected: selectedStepId === step.id,
},
}));
}, [design.steps, selectedStepId, handleStepDelete, handleStepDuplicate]);
// Auto-connect sequential steps based on position
const edges: Edge[] = useMemo(() => {
const sortedSteps = [...design.steps].sort(
(a, b) => a.position.x - b.position.x,
);
const newEdges: Edge[] = [];
for (let i = 0; i < sortedSteps.length - 1; i++) {
const sourceStep = sortedSteps[i];
const targetStep = sortedSteps[i + 1];
if (sourceStep && targetStep) {
// Only auto-connect if steps are reasonably close horizontally
const distance = Math.abs(
targetStep.position.x - sourceStep.position.x,
);
if (distance < 400) {
newEdges.push({
id: `${sourceStep.id}-${targetStep.id}`,
source: sourceStep.id,
sourceHandle: "output",
target: targetStep.id,
targetHandle: "input",
type: "smoothstep",
animated: true,
markerEnd: {
type: MarkerType.ArrowClosed,
width: 20,
height: 20,
color: "hsl(var(--muted-foreground))",
},
style: {
strokeWidth: 2,
stroke: "hsl(var(--muted-foreground))",
},
});
}
}
}
return newEdges;
}, [design.steps]);
const handleNodesChange = useCallback((changes: any[]) => {
// Update step positions when nodes are moved
const positionChanges = changes.filter(
(change) => change.type === "position" && change.position,
);
if (positionChanges.length > 0) {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((step) => {
const positionChange = positionChanges.find(
(change) => change.id === step.id,
);
if (positionChange && positionChange.position) {
return { ...step, position: positionChange.position };
}
return step;
}),
}));
setHasUnsavedChanges(true);
}
}, []);
const handleConnect = useCallback((params: Connection) => {
if (!params.source || !params.target) return;
// Update the design to reflect the new connection order
setDesign((prev) => {
const sourceStep = prev.steps.find((s) => s.id === params.source);
const targetStep = prev.steps.find((s) => s.id === params.target);
if (sourceStep && targetStep) {
// Automatically adjust positions to create a logical flow
const updatedSteps = prev.steps.map((step) => {
if (step.id === params.target) {
return {
...step,
position: {
x: Math.max(sourceStep.position.x + 300, step.position.x),
y: step.position.y,
},
};
}
return step;
});
setHasUnsavedChanges(true);
toast.success("Steps connected successfully");
return { ...prev, steps: updatedSteps };
}
return prev;
});
}, []);
const handleSave = async () => {
if (!onSave) return;
try {
const updatedDesign = {
...design,
version: design.version + 1,
lastSaved: new Date(),
};
await onSave(updatedDesign);
setDesign(updatedDesign);
setHasUnsavedChanges(false);
} catch (error) {
console.error("Save error:", error);
}
};
const handleStepUpdate = useCallback((updatedStep: FlowStep) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((step) =>
step.id === updatedStep.id ? updatedStep : step,
),
}));
setHasUnsavedChanges(true);
setEditingStep(null);
}, []);
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 border-b p-4 backdrop-blur">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="font-semibold">{design.name}</h2>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-amber-500 bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
Unsaved Changes
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled>
<Undo className="mr-2 h-4 w-4" />
Undo
</Button>
<Button variant="outline" size="sm" disabled>
<Redo className="mr-2 h-4 w-4" />
Redo
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
<Button variant="outline" size="sm">
<Upload className="mr-2 h-4 w-4" />
Import
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges}
size="sm"
>
<Save className="mr-2 h-4 w-4" />
{isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
{/* Main Flow Area */}
<div className="relative flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
connectionLineType={"smoothstep" as any}
snapToGrid={true}
snapGrid={[20, 20]}
defaultEdgeOptions={{
type: "smoothstep",
animated: true,
style: { strokeWidth: 2 },
}}
className="[&_.react-flow\_\_background]:bg-background [&_.react-flow\_\_controls]:bg-background [&_.react-flow\_\_controls]:border-border [&_.react-flow\_\_controls-button]:bg-background [&_.react-flow\_\_controls-button]:border-border [&_.react-flow\_\_controls-button]:text-foreground [&_.react-flow\_\_controls-button:hover]:bg-accent [&_.react-flow\_\_minimap]:bg-background [&_.react-flow\_\_minimap]:border-border [&_.react-flow\_\_edge-path]:stroke-muted-foreground [&_.react-flow\_\_controls]:shadow-sm [&_.react-flow\_\_edge-path]:stroke-2"
>
<Background
variant={"dots" as any}
gap={20}
size={1}
className="[&>*]:fill-muted-foreground/20"
/>
<Controls className="bg-background border-border rounded-lg shadow-lg" />
<MiniMap
nodeColor={(node) => {
const step = design.steps.find((s) => s.id === node.id);
return step
? stepTypeConfig[step.type].color
: "hsl(var(--muted))";
}}
className="bg-background border-border rounded-lg shadow-lg"
/>
{/* Step Library Panel */}
<Panel
position="top-left"
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
>
<div className="space-y-3">
<h4 className="text-sm font-medium">Add Step</h4>
<div className="grid grid-cols-2 gap-2">
{Object.entries(stepTypeConfig).map(([type, config]) => (
<Button
key={type}
variant="outline"
size="sm"
className="h-auto justify-start p-2"
onClick={() => handleStepTypeAdd(type as StepType)}
>
<div className="flex items-center gap-2">
<div
className={`${config.bgColor} flex h-6 w-6 shrink-0 items-center justify-center rounded shadow-lg`}
>
<config.icon className="h-3 w-3 text-white" />
</div>
<span className="text-xs">{config.label}</span>
</div>
</Button>
))}
</div>
</div>
</Panel>
{/* Info Panel */}
<Panel
position="top-right"
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Steps:</span>
<span className="font-medium">{design.steps.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Actions:</span>
<span className="font-medium">
{design.steps.reduce(
(sum, step) => sum + step.actions.length,
0,
)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-medium">v{design.version}</span>
</div>
</div>
</Panel>
</ReactFlow>
{/* Properties Sheet */}
{selectedStep && (
<Sheet
open={!!selectedStep}
onOpenChange={() => setSelectedStepId(undefined)}
>
<SheetContent>
<SheetHeader>
<SheetTitle>Step Properties</SheetTitle>
<SheetDescription>
Configure the selected step and its actions
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-4 pb-4">
<div className="space-y-2">
<Label htmlFor="step-name">Name</Label>
<Input
id="step-name"
value={selectedStep.name}
onChange={(e) => {
const updatedStep = {
...selectedStep,
name: e.target.value,
};
handleStepUpdate(updatedStep);
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="step-description">Description</Label>
<Textarea
id="step-description"
value={selectedStep.description ?? ""}
onChange={(e) => {
const updatedStep = {
...selectedStep,
description: e.target.value,
};
handleStepUpdate(updatedStep);
}}
placeholder="Optional description..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Step Type</Label>
<div className="flex items-center gap-2">
<div
className={`${stepTypeConfig[selectedStep.type].bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
>
{React.createElement(
stepTypeConfig[selectedStep.type].icon,
{
className: "h-4 w-4 text-white",
},
)}
</div>
<span className="text-sm font-medium">
{stepTypeConfig[selectedStep.type].label}
</span>
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Actions ({selectedStep.actions.length})</Label>
<Button size="sm" variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Action
</Button>
</div>
<ScrollArea className="mt-2 h-[200px]">
<div className="space-y-2 pr-4">
{selectedStep.actions.map((action) => {
const actionConfig = actionTypeConfig[action.type];
return (
<div
key={action.id}
className="bg-muted/50 flex items-center gap-2 rounded border p-2"
>
<actionConfig.icon className="text-muted-foreground h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{action.name}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
>
<Edit3 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</ScrollArea>
</div>
</div>
</SheetContent>
</Sheet>
)}
{/* Step Edit Dialog */}
{editingStep && (
<Sheet open={!!editingStep} onOpenChange={() => setEditingStep(null)}>
<SheetContent>
<SheetHeader>
<SheetTitle>Edit Step</SheetTitle>
<SheetDescription>
Modify step properties and actions
</SheetDescription>
</SheetHeader>
<div className="space-y-6 px-4 pb-4">
<div className="space-y-2">
<Label htmlFor="edit-step-name">Name</Label>
<Input
id="edit-step-name"
value={editingStep.name}
onChange={(e) => {
setEditingStep({ ...editingStep, name: e.target.value });
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-step-description">Description</Label>
<Textarea
id="edit-step-description"
value={editingStep.description ?? ""}
onChange={(e) => {
setEditingStep({
...editingStep,
description: e.target.value,
});
}}
placeholder="Optional description..."
rows={3}
/>
</div>
<div className="flex gap-2 pt-6">
<Button
onClick={() => {
handleStepUpdate(editingStep);
}}
className="flex-1"
>
Save Changes
</Button>
<Button
variant="outline"
onClick={() => setEditingStep(null)}
>
Cancel
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)}
</div>
</div>
);
}
export type { FlowDesign, FlowStep, FlowAction, StepType, ActionType };

View File

@@ -1,725 +0,0 @@
"use client";
import {
closestCenter, DndContext,
DragOverlay, PointerSensor, useDraggable,
useDroppable, useSensor,
useSensors, type DragEndEvent, type DragStartEvent
} from "@dnd-kit/core";
import {
Bot, Clock, Edit3, Grid, MessageSquare, Play, Redo, Save, Trash2, Undo, ZoomIn,
ZoomOut
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "~/components/ui/tooltip";
// Free-form element types
export type ElementType =
| "text"
| "action"
| "timer"
| "decision"
| "note"
| "group";
export interface CanvasElement {
id: string;
type: ElementType;
title: string;
content: string;
position: { x: number; y: number };
size: { width: number; height: number };
style: {
backgroundColor: string;
textColor: string;
borderColor: string;
fontSize: number;
};
metadata: Record<string, any>;
connections: string[]; // IDs of connected elements
}
export interface Connection {
id: string;
from: string;
to: string;
label?: string;
style: {
color: string;
width: number;
type: "solid" | "dashed" | "dotted";
};
}
export interface ExperimentDesign {
id: string;
name: string;
elements: CanvasElement[];
connections: Connection[];
canvasSettings: {
zoom: number;
gridSize: number;
showGrid: boolean;
backgroundColor: string;
};
version: number;
lastSaved: Date;
}
const elementTypeConfig = {
text: {
label: "Text Block",
description: "Add instructions or information",
icon: MessageSquare,
defaultStyle: {
backgroundColor: "#f8fafc",
textColor: "#1e293b",
borderColor: "#e2e8f0",
},
},
action: {
label: "Action Step",
description: "Define an action to be performed",
icon: Play,
defaultStyle: {
backgroundColor: "#dbeafe",
textColor: "#1e40af",
borderColor: "#3b82f6",
},
},
timer: {
label: "Timer",
description: "Add timing constraints",
icon: Clock,
defaultStyle: {
backgroundColor: "#fef3c7",
textColor: "#92400e",
borderColor: "#f59e0b",
},
},
decision: {
label: "Decision Point",
description: "Create branching logic",
icon: Bot,
defaultStyle: {
backgroundColor: "#dcfce7",
textColor: "#166534",
borderColor: "#22c55e",
},
},
note: {
label: "Research Note",
description: "Add researcher annotations",
icon: Edit3,
defaultStyle: {
backgroundColor: "#fce7f3",
textColor: "#be185d",
borderColor: "#ec4899",
},
},
group: {
label: "Group Container",
description: "Group related elements",
icon: Grid,
defaultStyle: {
backgroundColor: "#f3f4f6",
textColor: "#374151",
borderColor: "#9ca3af",
},
},
};
interface FreeFormDesignerProps {
experiment: {
id: string;
name: string;
description: string;
};
onSave?: (design: ExperimentDesign) => void;
initialDesign?: ExperimentDesign;
}
// Draggable element from toolbar
function ToolboxElement({ type }: { type: ElementType }) {
const config = elementTypeConfig[type];
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `toolbox-${type}`,
data: { type: "toolbox", elementType: type },
});
const style = {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
opacity: isDragging ? 0.5 : 1,
};
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className="flex cursor-grab flex-col items-center gap-2 rounded-lg border-2 border-dashed border-gray-300 p-3 transition-colors hover:border-gray-400 hover:bg-gray-50"
>
<config.icon className="h-6 w-6 text-gray-600" />
<span className="text-xs font-medium text-gray-700">
{config.label}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{config.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// Canvas element component
function CanvasElementComponent({
element,
isSelected,
onSelect,
onEdit,
onDelete,
}: {
element: CanvasElement;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const config = elementTypeConfig[element.type];
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: element.id,
data: { type: "canvas-element", element },
});
const style = {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
position: "absolute" as const,
left: element.position.x,
top: element.position.y,
width: element.size.width,
height: element.size.height,
backgroundColor: element.style.backgroundColor,
color: element.style.textColor,
borderColor: element.style.borderColor,
fontSize: element.style.fontSize,
opacity: isDragging ? 0.7 : 1,
zIndex: isSelected ? 10 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`cursor-pointer rounded-lg border-2 p-3 shadow-sm transition-all ${
isSelected ? "ring-2 ring-blue-500 ring-offset-2" : ""
}`}
onClick={onSelect}
{...listeners}
{...attributes}
>
<div className="flex items-start gap-2">
<config.icon className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium">{element.title}</h4>
<p className="mt-1 line-clamp-3 text-xs opacity-75">
{element.content}
</p>
</div>
</div>
{isSelected && (
<div className="absolute -top-2 -right-2 flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Edit3 className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
)}
</div>
);
}
// Canvas drop zone
function DesignCanvas({
children,
onDrop,
}: {
children: React.ReactNode;
onDrop: (position: { x: number; y: number }) => void;
}) {
const { setNodeRef, isOver } = useDroppable({
id: "design-canvas",
});
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
onDrop({ x, y });
}
},
[onDrop],
);
return (
<div
ref={setNodeRef}
className={`relative h-full w-full overflow-hidden bg-gray-50 ${
isOver ? "bg-blue-50" : ""
}`}
style={{
backgroundImage:
"radial-gradient(circle, #d1d5db 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
onClick={handleCanvasClick}
>
{children}
</div>
);
}
// Element editor dialog
function ElementEditor({
element,
isOpen,
onClose,
onSave,
}: {
element: CanvasElement | null;
isOpen: boolean;
onClose: () => void;
onSave: (element: CanvasElement) => void;
}) {
const [editingElement, setEditingElement] = useState<CanvasElement | null>(
element,
);
useEffect(() => {
setEditingElement(element);
}, [element]);
if (!editingElement) return null;
const handleSave = () => {
onSave(editingElement);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Element</DialogTitle>
<DialogDescription>
Customize the properties of this element.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={editingElement.title}
onChange={(e) =>
setEditingElement({
...editingElement,
title: e.target.value,
})
}
/>
</div>
<div>
<Label htmlFor="content">Content</Label>
<Textarea
id="content"
value={editingElement.content}
onChange={(e) =>
setEditingElement({
...editingElement,
content: e.target.value,
})
}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="width">Width</Label>
<Input
id="width"
type="number"
value={editingElement.size.width}
onChange={(e) =>
setEditingElement({
...editingElement,
size: {
...editingElement.size,
width: parseInt(e.target.value) || 200,
},
})
}
/>
</div>
<div>
<Label htmlFor="height">Height</Label>
<Input
id="height"
type="number"
value={editingElement.size.height}
onChange={(e) =>
setEditingElement({
...editingElement,
size: {
...editingElement.size,
height: parseInt(e.target.value) || 100,
},
})
}
/>
</div>
</div>
<div>
<Label htmlFor="backgroundColor">Background Color</Label>
<Input
id="backgroundColor"
type="color"
value={editingElement.style.backgroundColor}
onChange={(e) =>
setEditingElement({
...editingElement,
style: {
...editingElement.style,
backgroundColor: e.target.value,
},
})
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function FreeFormDesigner({
experiment,
onSave,
initialDesign,
}: FreeFormDesignerProps) {
const [design, setDesign] = useState<ExperimentDesign>(
initialDesign || {
id: experiment.id,
name: experiment.name,
elements: [],
connections: [],
canvasSettings: {
zoom: 1,
gridSize: 20,
showGrid: true,
backgroundColor: "#f9fafb",
},
version: 1,
lastSaved: new Date(),
},
);
const [selectedElement, setSelectedElement] = useState<string | null>(null);
const [editingElement, setEditingElement] = useState<CanvasElement | null>(
null,
);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [draggedElement, setDraggedElement] = useState<any>(null);
const canvasRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
);
const generateId = () =>
`element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const handleDragStart = (event: DragStartEvent) => {
setDraggedElement(event.active.data.current);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || over.id !== "design-canvas") {
setDraggedElement(null);
return;
}
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x =
event.delta.x + (active.rect.current.translated?.left || 0) - rect.left;
const y =
event.delta.y + (active.rect.current.translated?.top || 0) - rect.top;
const dragData = active.data.current;
if (dragData?.type === "toolbox") {
// Create new element from toolbox
createNewElement(dragData.elementType, { x, y });
} else if (dragData?.type === "canvas-element") {
// Move existing element
moveElement(dragData.element.id, { x, y });
}
setDraggedElement(null);
};
const createNewElement = (
type: ElementType,
position: { x: number; y: number },
) => {
const config = elementTypeConfig[type];
const newElement: CanvasElement = {
id: generateId(),
type,
title: `New ${config.label}`,
content: "Click to edit this element",
position,
size: { width: 200, height: 100 },
style: {
...config.defaultStyle,
fontSize: 14,
},
metadata: {},
connections: [],
};
setDesign((prev) => ({
...prev,
elements: [...prev.elements, newElement],
}));
};
const moveElement = (
elementId: string,
newPosition: { x: number; y: number },
) => {
setDesign((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === elementId ? { ...el, position: newPosition } : el,
),
}));
};
const deleteElement = (elementId: string) => {
setDesign((prev) => ({
...prev,
elements: prev.elements.filter((el) => el.id !== elementId),
connections: prev.connections.filter(
(conn) => conn.from !== elementId && conn.to !== elementId,
),
}));
setSelectedElement(null);
};
const editElement = (element: CanvasElement) => {
setEditingElement(element);
setIsEditDialogOpen(true);
};
const saveElement = (updatedElement: CanvasElement) => {
setDesign((prev) => ({
...prev,
elements: prev.elements.map((el) =>
el.id === updatedElement.id ? updatedElement : el,
),
}));
};
const handleSave = () => {
const updatedDesign = {
...design,
lastSaved: new Date(),
};
setDesign(updatedDesign);
onSave?.(updatedDesign);
};
const handleCanvasDrop = (position: { x: number; y: number }) => {
// Deselect when clicking empty space
setSelectedElement(null);
};
return (
<div className="flex h-screen bg-white">
{/* Toolbar */}
<div className="w-64 border-r bg-gray-50 p-4">
<div className="space-y-4">
<div>
<h3 className="font-medium text-gray-900">Element Toolbox</h3>
<p className="text-sm text-gray-500">Drag elements to the canvas</p>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(elementTypeConfig).map(([type, config]) => (
<ToolboxElement key={type} type={type as ElementType} />
))}
</div>
<Separator />
<div className="space-y-2">
<Button onClick={handleSave} className="w-full">
<Save className="mr-2 h-4 w-4" />
Save Design
</Button>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm">
<Undo className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<Redo className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" size="sm">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm">
<ZoomOut className="h-4 w-4" />
</Button>
</div>
</div>
<Separator />
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-900">Design Info</h4>
<div className="space-y-1 text-xs text-gray-500">
<div>Elements: {design.elements.length}</div>
<div>Last saved: {design.lastSaved.toLocaleTimeString()}</div>
<div>Version: {design.version}</div>
</div>
</div>
</div>
</div>
{/* Canvas */}
<div className="relative flex-1">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div ref={canvasRef} className="h-full">
<DesignCanvas onDrop={handleCanvasDrop}>
{design.elements.map((element) => (
<CanvasElementComponent
key={element.id}
element={element}
isSelected={selectedElement === element.id}
onSelect={() => setSelectedElement(element.id)}
onEdit={() => editElement(element)}
onDelete={() => deleteElement(element.id)}
/>
))}
</DesignCanvas>
</div>
<DragOverlay>
{draggedElement?.type === "toolbox" && (
<div className="rounded-lg border bg-white p-3 shadow-lg">
{(() => {
const IconComponent =
elementTypeConfig[draggedElement.elementType as ElementType]
.icon;
return <IconComponent className="h-6 w-6" />;
})()}
</div>
)}
{draggedElement?.type === "canvas-element" && (
<div className="rounded-lg border bg-white p-3 opacity-75 shadow-lg">
{draggedElement.element.title}
</div>
)}
</DragOverlay>
</DndContext>
</div>
{/* Element Editor Dialog */}
<ElementEditor
element={editingElement}
isOpen={isEditDialogOpen}
onClose={() => setIsEditDialogOpen(false)}
onSave={saveElement}
/>
</div>
);
}

View File

@@ -1,148 +0,0 @@
/* React Flow Theme Integration with shadcn/ui */
.react-flow {
background-color: hsl(var(--background));
}
.react-flow__background {
background-color: hsl(var(--background));
}
.react-flow__controls {
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.react-flow__controls-button {
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
transition: all 0.2s ease-in-out;
}
.react-flow__controls-button:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
.react-flow__controls-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.react-flow__minimap {
background-color: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.react-flow__minimap-mask {
fill: hsl(var(--primary) / 0.2);
stroke: hsl(var(--primary));
stroke-width: 2;
}
.react-flow__minimap-node {
fill: hsl(var(--muted));
stroke: hsl(var(--border));
}
.react-flow__edge-path {
stroke: hsl(var(--muted-foreground));
stroke-width: 2;
}
.react-flow__edge-text {
fill: hsl(var(--foreground));
font-size: 12px;
}
.react-flow__connection-line {
stroke: hsl(var(--primary));
stroke-width: 2;
stroke-dasharray: 5;
}
.react-flow__node.selected {
box-shadow: 0 0 0 2px hsl(var(--primary));
}
.react-flow__handle {
background-color: hsl(var(--primary));
border: 2px solid hsl(var(--background));
width: 8px;
height: 8px;
}
.react-flow__handle.connectingfrom {
background-color: hsl(var(--primary));
animation: pulse 1s infinite;
}
.react-flow__handle.connectingto {
background-color: hsl(var(--secondary));
}
.react-flow__background pattern circle {
fill: hsl(var(--muted-foreground) / 0.3);
}
.react-flow__background pattern rect {
fill: hsl(var(--muted-foreground) / 0.1);
}
/* Custom node animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
/* Custom edge animations */
.react-flow__edge.animated path {
stroke-dasharray: 5;
animation: dash 0.5s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -10;
}
}
/* Selection box */
.react-flow__selection {
background-color: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary));
}
/* Pane (canvas area) */
.react-flow__pane {
cursor: grab;
}
.react-flow__pane:active {
cursor: grabbing;
}
/* Attribution */
.react-flow__attribution {
color: hsl(var(--muted-foreground));
font-size: 10px;
}
.react-flow__attribution a {
color: hsl(var(--muted-foreground));
}
.react-flow__attribution a:hover {
color: hsl(var(--foreground));
}