feat(analytics): refine timeline visualization and add print support

This commit is contained in:
2026-02-17 21:17:11 -05:00
parent 568d408587
commit 72971a4b49
82 changed files with 6670 additions and 2448 deletions

View File

@@ -256,41 +256,35 @@ function ExperimentActions({ experiment }: { experiment: Experiment }) {
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Metadata
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Design
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 focus:text-red-700"
onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
asChild
className="h-8 w-8 text-muted-foreground hover:text-primary"
title="Open Designer"
>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (confirm("Are you sure you want to delete this experiment?")) {
deleteMutation.mutate({ id: experiment.id });
}
}}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</div>
);
}

View File

@@ -17,12 +17,14 @@ import {
PanelRightClose,
PanelRightOpen,
Maximize2,
Minimize2
Minimize2,
Settings
} from "lucide-react";
import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header";
import { useTour } from "~/components/onboarding/TourProvider";
import { SettingsModal } from "./SettingsModal";
import { Button } from "~/components/ui/button";
import { api } from "~/trpc/react";
@@ -45,7 +47,8 @@ import {
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
import { InspectorPanel } from "./panels/InspectorPanel";
import { FlowWorkspace, SortableActionChip, StepCardPreview } from "./flow/FlowWorkspace";
import { FlowWorkspace, StepCardPreview } from "./flow/FlowWorkspace";
import { SortableActionChip } from "./flow/ActionChip";
import { GripVertical } from "lucide-react";
import {
@@ -96,6 +99,23 @@ export interface DesignerRootProps {
initialDesign?: ExperimentDesign;
autoCompile?: boolean;
onPersist?: (design: ExperimentDesign) => void;
experiment?: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
interface RawExperiment {
@@ -114,10 +134,13 @@ interface RawExperiment {
/* -------------------------------------------------------------------------- */
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
console.log('[adaptExistingDesign] Entry - exp.steps:', exp.steps);
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
// plugin provenance data (which might be missing from stale visualDesign snapshots).
// 1. Prefer database steps (Source of Truth) if valid.
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
console.log('[adaptExistingDesign] Has steps array, length:', exp.steps.length);
try {
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
const firstStep = exp.steps[0] as any;
@@ -128,7 +151,17 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
dbSteps = exp.steps as ExperimentStep[];
} else {
// Raw DB steps, need conversion
console.log('[adaptExistingDesign] Taking raw DB conversion path');
dbSteps = convertDatabaseToSteps(exp.steps);
// DEBUG: Check children after conversion
dbSteps.forEach((step) => {
step.actions.forEach((action) => {
if (["sequence", "parallel", "loop", "branch"].includes(action.type)) {
console.log(`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`, action.children);
}
});
});
}
return {
@@ -196,6 +229,8 @@ export function DesignerRoot({
initialDesign,
autoCompile = true,
onPersist,
experiment: experimentMetadata,
designStats,
}: DesignerRootProps) {
// Subscribe to registry updates to ensure re-renders when actions load
useActionRegistry();
@@ -218,8 +253,6 @@ export function DesignerRoot({
}
);
const updateExperiment = api.experiments.update.useMutation({
onError: (err) => {
toast.error(`Save failed: ${err.message}`);
@@ -321,6 +354,7 @@ export function DesignerRoot({
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
useEffect(() => {
@@ -353,14 +387,12 @@ export function DesignerRoot({
/* ----------------------------- Initialization ---------------------------- */
useEffect(() => {
console.log('[DesignerRoot] useEffect triggered', { initialized, loadingExperiment, hasExperiment: !!experiment, hasInitialDesign: !!initialDesign });
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
// console.log('[DesignerRoot] 🚀 INITIALIZING', {
// hasExperiment: !!experiment,
// hasInitialDesign: !!initialDesign,
// loadingExperiment,
// });
console.log('[DesignerRoot] Proceeding with initialization');
const adapted =
initialDesign ??
@@ -1004,10 +1036,8 @@ export function DesignerRoot({
const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) {
for (const param of fullDef.parameters) {
// @ts-expect-error - 'default' property access
if (param.default !== undefined) {
// @ts-expect-error - 'default' property access
defaultParams[param.id] = param.default;
if (param.value !== undefined) {
defaultParams[param.id] = param.value;
}
}
}
@@ -1097,6 +1127,16 @@ export function DesignerRoot({
const actions = (
<div className="flex flex-wrap items-center gap-2">
{experimentMetadata && (
<Button
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
title="Experiment Settings"
>
<Settings className="h-5 w-5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
@@ -1127,7 +1167,10 @@ export function DesignerRoot({
);
return (
<div className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
<div className="relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
{/* Subtle Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full bg-primary/10 blur-3xl opacity-20 dark:opacity-10" />
<div className="absolute bottom-0 right-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
<PageHeader
title={designMeta.name}
description={designMeta.description || "No description"}
@@ -1289,6 +1332,16 @@ export function DesignerRoot({
</DragOverlay>
</DndContext>
</div>
{/* Settings Modal */}
{experimentMetadata && (
<SettingsModal
open={settingsOpen}
onOpenChange={setSettingsOpen}
experiment={experimentMetadata}
designStats={designStats}
/>
)}
</div>
);
}

View File

@@ -170,7 +170,30 @@ export function PropertiesPanelBase({
/* -------------------------- Action Properties View -------------------------- */
if (selectedAction && containingStep) {
const def = registry.getAction(selectedAction.type);
let def = registry.getAction(selectedAction.type);
// Fallback: If action not found in registry, try without plugin prefix
if (!def && selectedAction.type.includes('.')) {
const baseType = selectedAction.type.split('.').pop();
if (baseType) {
def = registry.getAction(baseType);
}
}
// Final fallback: Create minimal definition from action data
if (!def) {
def = {
id: selectedAction.type,
type: selectedAction.type,
name: selectedAction.name,
description: `Action type: ${selectedAction.type}`,
category: selectedAction.category || 'control',
icon: 'Zap',
color: '#6366f1',
parameters: [],
source: selectedAction.source,
};
}
const categoryColors = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
@@ -289,23 +312,29 @@ export function PropertiesPanelBase({
size="sm"
className="h-5 w-5 p-0"
onClick={() => {
const currentOptions = (containingStep.trigger.conditions as any)?.options || [];
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const newOptions = [
...currentOptions,
{ label: "New Option", nextStepId: design.steps[containingStep.order + 1]?.id, variant: "default" }
];
// Sync to Step Trigger (Source of Truth)
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: {
...containingStep.trigger.conditions,
options: [
...currentOptions,
{ label: "New Option", nextStepIndex: containingStep.order + 1, variant: "default" }
]
options: newOptions
}
}
});
// Auto-upgrade step type if needed
if (containingStep.type !== "conditional") {
onStepUpdate(containingStep.id, { type: "conditional" });
}
// Sync to Action Params (for consistency)
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
options: newOptions
}
});
}}
>
<Plus className="h-3.5 w-3.5" />
@@ -313,64 +342,86 @@ export function PropertiesPanelBase({
</div>
<div className="space-y-3">
{((containingStep.trigger.conditions as any)?.options || []).map((opt: any, idx: number) => (
{(((containingStep.trigger.conditions as any).options as any[]) || []).map((opt: any, idx: number) => (
<div key={idx} className="space-y-2 p-2 rounded border bg-muted/50">
<div className="flex gap-2">
<div className="flex-1">
<div className="grid grid-cols-5 gap-2">
<div className="col-span-3">
<Label className="text-[10px]">Label</Label>
<Input
value={opt.label}
onChange={(e) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
});
}}
className="h-7 text-xs"
/>
</div>
<div className="w-[80px]">
<div className="col-span-2">
<Label className="text-[10px]">Target Step</Label>
<Select
value={opt.nextStepId ?? design.steps[opt.nextStepIndex]?.id ?? ""}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
// Find index for legacy support / display logic if needed
const stepIdx = design.steps.findIndex(s => s.id === val);
{design.steps.length <= 1 ? (
<div className="h-7 flex items-center text-[10px] text-muted-foreground border rounded px-2 bg-muted/50 truncate" title="Add more steps to link">
No linkable steps
</div>
) : (
<Select
value={opt.nextStepId ?? ""}
onValueChange={(val) => {
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
newOpts[idx] = {
...newOpts[idx],
nextStepId: val,
nextStepIndex: stepIdx !== -1 ? stepIdx : undefined
};
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="Select step..." />
</SelectTrigger>
<SelectContent>
{design.steps.map((s) => (
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
{s.order + 1}. {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
onStepUpdate(containingStep.id, {
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
});
}}
>
<SelectTrigger className="h-7 text-xs w-full">
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent className="min-w-[180px]">
{design.steps.map((s) => (
<SelectItem key={s.id} value={s.id} disabled={s.id === containingStep.id}>
{s.order + 1}. {s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<div className="flex items-center justify-between">
<Select
value={opt.variant || "default"}
onValueChange={(val) => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const newOpts = [...currentOptions];
newOpts[idx] = { ...newOpts[idx], variant: val };
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
});
}}
>
@@ -389,10 +440,18 @@ export function PropertiesPanelBase({
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
onClick={() => {
const newOpts = [...((containingStep.trigger.conditions as any)?.options || [])];
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
const newOpts = [...currentOptions];
newOpts.splice(idx, 1);
onStepUpdate(containingStep.id, {
trigger: { ...containingStep.trigger, conditions: { ...containingStep.trigger.conditions, options: newOpts } }
trigger: {
...containingStep.trigger,
conditions: { ...containingStep.trigger.conditions, options: newOpts }
}
});
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: { ...selectedAction.parameters, options: newOpts }
});
}}
>
@@ -401,13 +460,46 @@ export function PropertiesPanelBase({
</div>
</div>
))}
{(!((containingStep.trigger.conditions as any)?.options?.length)) && (
{(!(((containingStep.trigger.conditions as any).options as any[])?.length)) && (
<div className="text-center py-4 border border-dashed rounded text-xs text-muted-foreground">
No options defined.<br />Click + to add a branch.
</div>
)}
</div>
</div>
) : selectedAction.type === "loop" ? (
/* Loop Configuration */
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Loop Configuration
</div>
<div className="space-y-4">
{/* Iterations */}
<div>
<Label className="text-xs">Iterations</Label>
<div className="flex items-center gap-2 mt-1">
<Slider
min={1}
max={20}
step={1}
value={[Number(selectedAction.parameters.iterations || 1)]}
onValueChange={(vals) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
iterations: vals[0],
},
});
}}
/>
<span className="text-xs font-mono w-8 text-right">
{Number(selectedAction.parameters.iterations || 1)}
</span>
</div>
</div>
</div>
</div>
) : (
/* Standard Parameters */
def?.parameters.length ? (

View File

@@ -0,0 +1,53 @@
"use client";
import { SettingsTab } from "./tabs/SettingsTab";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsModal({
open,
onOpenChange,
experiment,
designStats,
}: SettingsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="sr-only">
<DialogTitle>Experiment Settings</DialogTitle>
<DialogDescription>
Configure experiment metadata and status
</DialogDescription>
</DialogHeader>
<SettingsTab experiment={experiment} designStats={designStats} />
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,503 @@
"use client";
import React, { useMemo } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDroppable } from "@dnd-kit/core";
import {
ChevronRight,
Trash2,
Clock,
GitBranch,
Repeat,
Layers,
List,
AlertCircle,
Play,
HelpCircle
} from "lucide-react";
import { cn } from "~/lib/utils";
import { type ExperimentAction } from "~/lib/experiment-designer/types";
import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { useDesignerStore } from "../state/store";
export interface ActionChipProps {
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
}
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: 'up' | 'down') => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
/**
* Helper to determine visual style based on action type/category
*/
function getActionVisualStyle(action: ExperimentAction) {
const def = actionRegistry.getAction(action.type);
const category = def?.category || "other";
// Specific Control Types
if (action.type === "hristudio-core.wait" || action.type === "wait") {
return {
variant: "wait",
icon: Clock,
bg: "bg-amber-500/10 hover:bg-amber-500/20",
border: "border-amber-200 dark:border-amber-800",
text: "text-amber-700 dark:text-amber-400",
accent: "bg-amber-500",
};
}
if (action.type === "hristudio-core.branch" || action.type === "branch") {
return {
variant: "branch",
icon: GitBranch,
bg: "bg-orange-500/10 hover:bg-orange-500/20",
border: "border-orange-200 dark:border-orange-800",
text: "text-orange-700 dark:text-orange-400",
accent: "bg-orange-500",
};
}
if (action.type === "hristudio-core.loop" || action.type === "loop") {
return {
variant: "loop",
icon: Repeat,
bg: "bg-purple-500/10 hover:bg-purple-500/20",
border: "border-purple-200 dark:border-purple-800",
text: "text-purple-700 dark:text-purple-400",
accent: "bg-purple-500",
};
}
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
return {
variant: "parallel",
icon: Layers,
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
border: "border-emerald-200 dark:border-emerald-800",
text: "text-emerald-700 dark:text-emerald-400",
accent: "bg-emerald-500",
};
}
// General Categories
if (category === "wizard") {
return {
variant: "wizard",
icon: HelpCircle,
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
border: "border-indigo-200 dark:border-indigo-800",
text: "text-indigo-700 dark:text-indigo-300",
accent: "bg-indigo-500",
};
}
if ((category as string) === "robot" || (category as string) === "movement" || (category as string) === "speech") {
return {
variant: "robot",
icon: Play, // Or specific robot icon if available
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
border: "border-slate-200 dark:border-slate-700",
text: "text-slate-700 dark:text-slate-300",
accent: "bg-slate-500",
}
}
// Default
return {
variant: "default",
icon: undefined,
bg: "bg-muted/40 hover:bg-accent/40",
border: "border-border",
text: "text-foreground",
accent: "bg-muted-foreground",
};
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
const style = getActionVisualStyle(action);
const Icon = style.icon;
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
style.bg,
style.border,
isSelected && "ring-2 ring-primary border-primary bg-accent/50",
isDragging && "opacity-70 shadow-lg scale-95",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50 dark:bg-blue-900/20"
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
{/* Accent Bar logic for control flow */}
{style.variant !== "default" && style.variant !== "robot" && (
<div className={cn("absolute left-0 top-0 bottom-0 w-1 rounded-l", style.accent)} />
)}
<div className={cn("flex w-full items-center gap-2", style.variant !== "default" && style.variant !== "robot" && "pl-2")}>
<div className="flex items-center gap-2 flex-1 min-w-0">
{Icon && <Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />}
<span className={cn("leading-snug font-medium break-words truncate", style.text)}>
{action.name}
</span>
{/* Inline Info for Control Actions */}
{style.variant === "wait" && !!action.parameters.duration && (
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
{String(action.parameters.duration ?? "")}s
</span>
)}
{style.variant === "loop" && (
<span className="ml-1 text-[10px] bg-background/50 px-1.5 py-0.5 rounded font-mono text-muted-foreground">
{String(action.parameters.iterations || 1)}x
</span>
)}
{style.variant === "loop" && action.parameters.requireApproval !== false && (
<span className="ml-1 text-[10px] bg-purple-500/20 px-1.5 py-0.5 rounded font-mono text-purple-700 dark:text-purple-300 flex items-center gap-0.5" title="Requires Wizard Approval">
<HelpCircle className="h-2 w-2" />
Ask
</span>
)}
{validationStatus === "error" && (
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600 flex-shrink-0" aria-label="Error" />
)}
{validationStatus === "warning" && (
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600 flex-shrink-0" aria-label="Warning" />
)}
</div>
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Description / Subtext */}
{
def?.description && (
<div className={cn("text-muted-foreground line-clamp-2 w-full text-[10px] leading-snug pl-2 mt-0.5", style.variant !== "default" && style.variant !== "robot" && "pl-4")}>
{def.description}
</div>
)
}
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
{
def?.parameters?.length && (style.variant === 'default' || style.variant === 'robot') ? (
<div className="flex flex-wrap gap-1 pt-1">
{def.parameters.slice(0, 3).map((p) => (
<span
key={p.id}
className="bg-background/80 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1 truncate max-w-[80px]"
>
{p.name}
</span>
))}
{def.parameters.length > 3 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 3}</span>
)}
</div>
) : null
}
{children}
</div >
);
}
export function SortableActionChip({
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
onReorderAction,
dragHandle,
isFirst,
isLast,
}: ActionChipProps) {
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const steps = useDesignerStore((s) => s.steps);
const currentStep = steps.find((s) => s.id === stepId);
// Branch Options Visualization
const branchOptions = useMemo(() => {
if (!action.type.includes("branch") || !currentStep) return null;
const options = (currentStep.trigger as any)?.conditions?.options;
if (!options?.length && !(currentStep.trigger as any)?.conditions?.nextStepId) {
return (
<div className="mt-2 text-muted-foreground/60 italic text-center py-2 text-[10px] bg-background/50 rounded border border-dashed">
No branches configured. Add options in properties.
</div>
);
}
// Combine explicit options and unconditional nextStepId
// The original FlowWorkspace logic iterated options. logic there:
// (step.trigger.conditions as any).options.map...
return (
<div className="mt-2 space-y-1 w-full">
{options?.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = steps.find(s => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === 'number') {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-1.5 text-[10px]">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[9px] uppercase font-bold tracking-wider px-1 py-0 min-w-[60px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[60%] justify-end">
<span className="font-medium truncate text-foreground/80" title={targetName}>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge variant="secondary" className="px-1 py-0 h-3.5 text-[9px] min-w-[18px] justify-center tabular-nums bg-slate-100 dark:bg-slate-800">
#{targetIndex + 1}
</Badge>
)}
</div>
</div>
);
})}
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
{/* For now keeping parity with FlowWorkspace which only showed options */}
</div>
);
}, [action.type, currentStep, steps]);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children || [];
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = !!def?.nestable;
if (isPlaceholder) {
return (
<div
className={cn(
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
"bg-blue-50/50 dark:bg-blue-900/20 border-blue-400 opacity-70"
)}
>
<div className="flex w-full items-center gap-2">
<span className="font-medium text-blue-700 italic">
{action.name}
</span>
</div>
</div >
);
}
return (
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested && !isDragging}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
isFirst={isFirst}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Branch Options Visualization */}
{branchOptions}
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
isOverNested
? "bg-blue-100/50 dark:bg-blue-900/20 border-blue-400"
: "bg-muted/20 dark:bg-muted/10 border-border/50"
)}
>
{displayChildren?.length === 0 ? (
<div className="py-2 text-center text-[10px] text-muted-foreground/60 italic">
Empty container
</div>
) : (
displayChildren?.map((child, idx) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
isFirst={idx === 0}
isLast={idx === (displayChildren?.length || 0) - 1}
/>
))
)}
</div>
)}
</ActionChipVisuals>
);
}

View File

@@ -30,6 +30,7 @@ import {
GitBranch,
Edit3,
CornerDownRight,
Repeat,
} from "lucide-react";
import { cn } from "~/lib/utils";
import {
@@ -41,6 +42,7 @@ import { actionRegistry } from "../ActionRegistry";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Input } from "~/components/ui/input";
import { SortableActionChip } from "./ActionChip";
/**
* FlowWorkspace
@@ -296,75 +298,41 @@ function StepRow({
{/* Conditional Branching Visualization */}
{/* Conditional Branching Visualization */}
{step.type === "conditional" && (
{/* Loop Visualization */}
{step.type === "loop" && (
<div className="mx-3 my-3 rounded-md border text-xs" style={{
backgroundColor: 'var(--validation-warning-bg)', // Semantic background
borderColor: 'var(--validation-warning-border)', // Semantic border
backgroundColor: 'var(--validation-info-bg, #f0f9ff)',
borderColor: 'var(--validation-info-border, #bae6fd)',
}}>
<div className="flex items-center gap-2 border-b px-3 py-2 font-medium" style={{
borderColor: 'var(--validation-warning-border)',
color: 'var(--validation-warning-text)'
borderColor: 'var(--validation-info-border, #bae6fd)',
color: 'var(--validation-info-text, #0369a1)'
}}>
<GitBranch className="h-3.5 w-3.5" />
<span>Branching Logic</span>
<Repeat className="h-3.5 w-3.5" />
<span>Loop Logic</span>
</div>
<div className="p-2 space-y-2">
{!(step.trigger.conditions as any)?.options?.length ? (
<div className="text-muted-foreground/60 italic text-center py-2 text-[11px]">
No branches configured. Add options in properties.
</div>
) : (
(step.trigger.conditions as any).options.map((opt: any, idx: number) => {
// Resolve ID to name for display
let targetName = "Unlinked";
let targetIndex = -1;
if (opt.nextStepId) {
const target = allSteps.find(s => s.id === opt.nextStepId);
if (target) {
targetName = target.name;
targetIndex = target.order;
}
} else if (typeof opt.nextStepIndex === 'number') {
targetIndex = opt.nextStepIndex;
targetName = `Step #${targetIndex + 1}`;
}
return (
<div key={idx} className="flex items-center justify-between rounded bg-background/50 shadow-sm border p-2">
<div className="flex items-center gap-2 min-w-0">
<Badge variant="outline" className={cn(
"text-[10px] uppercase font-bold tracking-wider px-1.5 py-0.5 min-w-[70px] justify-center bg-background",
opt.variant === "destructive"
? "border-red-500/30 text-red-600 dark:text-red-400"
: "border-slate-500/30 text-foreground"
)}>
{opt.label}
</Badge>
<span className="text-muted-foreground text-[10px]">then go to</span>
</div>
<div className="flex items-center gap-1.5 text-right min-w-0 max-w-[50%]">
<span className="font-medium truncate text-[11px] block text-foreground" title={targetName}>
{targetName}
</span>
{targetIndex !== -1 && (
<Badge variant="secondary" className="px-1 py-0 h-4 text-[9px] min-w-[20px] justify-center tabular-nums">
#{targetIndex + 1}
</Badge>
)}
<ChevronRight className="h-3 w-3 text-muted-foreground/50 flex-shrink-0" />
</div>
</div>
);
})
)}
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Repeat:</span>
<Badge variant="outline" className="font-mono">
{(step.trigger.conditions as any).loop?.iterations || 1} times
</Badge>
</div>
<div className="flex items-center gap-2 text-[11px]">
<span className="text-muted-foreground">Approval:</span>
<Badge variant={(step.trigger.conditions as any).loop?.requireApproval !== false ? "default" : "secondary"}>
{(step.trigger.conditions as any).loop?.requireApproval !== false ? "Required" : "Auto-proceed"}
</Badge>
</div>
</div>
</div>
)}
{/* Action List (Collapsible/Virtual content) */}
{step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
@@ -497,330 +465,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
);
}
/* -------------------------------------------------------------------------- */
/* Sortable Action Chip */
/* -------------------------------------------------------------------------- */
export interface ActionChipProps {
stepId: string;
action: ExperimentAction;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
dragHandle?: boolean;
isFirst?: boolean;
isLast?: boolean;
}
/* -------------------------------------------------------------------------- */
/* Action Chip Visuals (Pure Component) */
/* -------------------------------------------------------------------------- */
export interface ActionChipVisualsProps {
action: ExperimentAction;
isSelected?: boolean;
isDragging?: boolean;
isOverNested?: boolean;
onSelect?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
onReorder?: (direction: 'up' | 'down') => void;
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
children?: React.ReactNode;
isFirst?: boolean;
isLast?: boolean;
validationStatus?: "error" | "warning" | "info";
}
export function ActionChipVisuals({
action,
isSelected,
isDragging,
isOverNested,
onSelect,
onDelete,
onReorder,
dragHandleProps,
children,
isFirst,
isLast,
validationStatus,
}: ActionChipVisualsProps) {
const def = actionRegistry.getAction(action.type);
return (
<div
className={cn(
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)}
onClick={onSelect}
role="button"
aria-pressed={isSelected}
tabIndex={0}
>
<div className="flex w-full items-center gap-2">
<span className="flex-1 leading-snug font-medium break-words flex items-center gap-2">
{action.name}
{validationStatus === "error" && (
<div className="h-2 w-2 rounded-full bg-red-500 ring-1 ring-red-600" aria-label="Error" />
)}
{validationStatus === "warning" && (
<div className="h-2 w-2 rounded-full bg-amber-500 ring-1 ring-amber-600" aria-label="Warning" />
)}
</span>
<div className="flex items-center gap-0.5 mr-1 bg-background/50 rounded-md border border-border/50 shadow-sm px-0.5">
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('up');
}}
disabled={isFirst}
aria-label="Move action up"
>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-[10px] text-muted-foreground hover:text-foreground z-20 pointer-events-auto"
onClick={(e) => {
e.stopPropagation();
onReorder?.('down');
}}
disabled={isLast}
aria-label="Move action down"
>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
</div>
<button
type="button"
onClick={onDelete}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
{def?.parameters.length ? (
<div className="flex flex-wrap gap-1 pt-0.5">
{def.parameters.slice(0, 4).map((p) => (
<span
key={p.id}
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
>
{p.name}
</span>
))}
{def.parameters.length > 4 && (
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{children}
</div>
);
}
export function SortableActionChip({
stepId,
action,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
onReorderAction,
dragHandle,
isFirst,
isLast,
}: ActionChipProps) {
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children;
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
// Compute validation status
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
const validationStatus = useMemo(() => {
if (!issues?.length) return undefined;
if (issues.some((i) => i.severity === "error")) return "error";
if (issues.some((i) => i.severity === "warning")) return "warning";
return "info";
}, [issues]);
/* ------------------------------------------------------------------------ */
/* Sortable (Local) DnD Monitoring */
/* ------------------------------------------------------------------------ */
// useSortable disabled per user request to remove action drag-and-drop
// const { ... } = useSortable(...)
// Use local dragging state or passed prop
const isDragging = dragHandle || false;
const style = {
// transform: CSS.Translate.toString(transform),
// transition,
};
// We need a ref for droppable? Droppable is below.
// For the chip itself, if not sortable, we don't need setNodeRef.
// But we might need it for layout?
// Let's keep a simple div ref usage if needed, but useSortable provided setNodeRef.
// We can just use a normal ref or nothing if not measuring.
const setNodeRef = undefined; // No-op
const attributes = {};
const listeners = {};
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const def = actionRegistry.getAction(action.type);
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = def?.nestable;
if (isPlaceholder) {
const { setNodeRef: setPlaceholderRef } = useDroppable({
id: "projection-placeholder",
data: { type: "placeholder" }
});
// Render simplified placeholder without hooks refs
// We still render the content matching the action type for visual fidelity
return (
<div
ref={setPlaceholderRef}
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
>
<div className="flex w-full items-center gap-2">
<span className={cn(
"h-2.5 w-2.5 rounded-full",
def ? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category] : "bg-gray-400"
)} />
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
</div>
);
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
>
<ActionChipVisuals
action={action}
isSelected={isSelected}
isDragging={isDragging}
isOverNested={isOverNested}
onSelect={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
onDelete={(e) => {
e.stopPropagation();
onDeleteAction(stepId, action.id);
}}
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
dragHandleProps={listeners}
isLast={isLast}
validationStatus={validationStatus}
>
{/* Nested Actions Container */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
)}
>
<SortableContext
items={(displayChildren ?? action.children ?? [])
.filter(c => c.id !== "projection-placeholder")
.map(c => sortableActionId(c.id))}
strategy={verticalListSortingStrategy}
>
{(displayChildren || action.children || []).map((child) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
onReorderAction={onReorderAction}
/>
))}
{(!displayChildren?.length && !action.children?.length) && (
<div className="text-[10px] text-muted-foreground/60 italic py-1">
Drag actions here
</div>
)}
</SortableContext>
</div>
)}
</ActionChipVisuals>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* FlowWorkspace Component */

View File

@@ -158,10 +158,17 @@ export interface DesignerState {
/* Helpers */
/* -------------------------------------------------------------------------- */
function cloneActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({
...a,
children: a.children ? cloneActions(a.children) : undefined,
}));
}
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps.map((s) => ({
...s,
actions: s.actions.map((a) => ({ ...a })),
actions: cloneActions(s.actions),
}));
}
@@ -248,8 +255,10 @@ function insertActionIntoTree(
/* Store Implementation */
/* -------------------------------------------------------------------------- */
export const useDesignerStore = create<DesignerState>((set, get) => ({
steps: [],
export const createDesignerStore = (props: {
initialSteps?: ExperimentStep[];
}) => create<DesignerState>((set, get) => ({
steps: props.initialSteps ? reindexSteps(cloneSteps(props.initialSteps)) : [],
dirtyEntities: new Set<string>(),
validationIssues: {},
actionSignatureIndex: new Map(),
@@ -541,6 +550,8 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
}),
}));
export const useDesignerStore = createDesignerStore({});
/* -------------------------------------------------------------------------- */
/* Convenience Selectors */
/* -------------------------------------------------------------------------- */

View File

@@ -0,0 +1,294 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { experimentStatusEnum } from "~/server/db/schema";
import { Save, ExternalLink } from "lucide-react";
import Link from "next/link";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum(experimentStatusEnum.enumValues),
});
interface SettingsTabProps {
experiment: {
id: string;
name: string;
description: string | null;
status: string;
studyId: string;
createdAt: Date;
updatedAt: Date;
study: {
id: string;
name: string;
};
};
designStats?: {
stepCount: number;
actionCount: number;
};
}
export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
const utils = api.useUtils();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment settings saved successfully");
// Invalidate experiments list to refresh data
await utils.experiments.list.invalidate();
},
onError: (error) => {
toast.error(`Error saving settings: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status as z.infer<typeof formSchema>["status"],
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
const isDirty = form.formState.isDirty;
return (
<div className="h-full overflow-y-auto p-6">
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold tracking-tight">Experiment Settings</h2>
<p className="text-muted-foreground mt-1">
Configure experiment metadata and status
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Left Column: Basic Information (Spans 2) */}
<div className="md:col-span-2 space-y-6">
<Card className="h-full">
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>
The name and description help identify this experiment
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
A clear, descriptive name for your experiment
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
className="resize-none min-h-[300px]"
{...field}
/>
</FormControl>
<FormDescription>
Detailed description of the experiment purpose and design
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
{/* Right Column: Status & Metadata (Spans 1) */}
<div className="space-y-6">
{/* Status Card */}
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>
Track lifecycle stage
</CardDescription>
</CardHeader>
<CardContent>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Current Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">
<div className="flex items-center gap-2">
<Badge variant="secondary">Draft</Badge>
<span className="text-xs text-muted-foreground">WIP</span>
</div>
</SelectItem>
<SelectItem value="testing">
<div className="flex items-center gap-2">
<Badge variant="outline">Testing</Badge>
<span className="text-xs text-muted-foreground">Validation</span>
</div>
</SelectItem>
<SelectItem value="ready">
<div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500">Ready</Badge>
<span className="text-xs text-muted-foreground">Live</span>
</div>
</SelectItem>
<SelectItem value="deprecated">
<div className="flex items-center gap-2">
<Badge variant="destructive">Deprecated</Badge>
<span className="text-xs text-muted-foreground">Retired</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Metadata Card */}
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
<CardDescription>
Read-only information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Study</p>
<Link
href={`/studies/${experiment.study.id}`}
className="text-sm hover:underline flex items-center gap-1 text-primary truncate"
>
{experiment.study.name}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</Link>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Experiment ID</p>
<p className="text-xs font-mono bg-muted p-1 rounded select-all">{experiment.id.split('-')[0]}...</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Created</p>
<p className="text-xs">{new Date(experiment.createdAt).toLocaleDateString()}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Updated</p>
<p className="text-xs">{new Date(experiment.updatedAt).toLocaleDateString()}</p>
</div>
</div>
</div>
{designStats && (
<div className="pt-4 border-t">
<p className="text-xs font-medium text-muted-foreground mb-2">Statistics</p>
<div className="flex flex-wrap gap-2">
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
<span className="font-semibold">{designStats.stepCount}</span>
<span className="text-muted-foreground">Steps</span>
</div>
<div className="flex items-center gap-1.5 bg-muted/50 px-2 py-1 rounded text-xs">
<span className="font-semibold">{designStats.actionCount}</span>
<span className="text-muted-foreground">Actions</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t">
<Button
type="submit"
disabled={updateExperiment.isPending || !isDirty}
className="min-w-[120px]"
>
{updateExperiment.isPending ? (
"Saving..."
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</form>
</Form>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
Edit,
Eye,
FlaskConical,
LayoutTemplate,
MoreHorizontal,
Play,
TestTube,
@@ -99,43 +100,33 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
asChild
className="h-8 w-8 text-muted-foreground hover:text-primary"
title="Open Designer"
>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="h-4 w-4" />
<span className="sr-only">Design</span>
</Link>
</Button>
{experiment.canDelete && (
<Button
variant="ghost"
size="icon"
onClick={handleDelete}
className="h-8 w-8 text-muted-foreground hover:text-destructive"
title="Delete Experiment"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Metadata
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" />
Design
</Link>
</DropdownMenuItem>
{experiment.canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}