mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat(analytics): refine timeline visualization and add print support
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
53
src/components/experiments/designer/SettingsModal.tsx
Normal file
53
src/components/experiments/designer/SettingsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
503
src/components/experiments/designer/flow/ActionChip.tsx
Normal file
503
src/components/experiments/designer/flow/ActionChip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
294
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal file
294
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user