mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-05-08 13:58:55 -04:00
cf21a27995
- Allow typing numbers directly in slider inputs - Use textarea for text parameters like say_text
1044 lines
36 KiB
TypeScript
Executable File
1044 lines
36 KiB
TypeScript
Executable File
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
SelectContent,
|
|
SelectItem,
|
|
} from "~/components/ui/select";
|
|
import { Switch } from "~/components/ui/switch";
|
|
import { Slider } from "~/components/ui/slider";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { cn } from "~/lib/utils";
|
|
import {
|
|
TRIGGER_OPTIONS,
|
|
type ExperimentAction,
|
|
type ExperimentStep,
|
|
type StepType,
|
|
type TriggerType,
|
|
type ExperimentDesign,
|
|
} from "~/lib/experiment-designer/types";
|
|
import { actionRegistry } from "./ActionRegistry";
|
|
import { Button } from "~/components/ui/button";
|
|
import {
|
|
Settings,
|
|
Zap,
|
|
MessageSquare,
|
|
Hand,
|
|
Navigation,
|
|
Volume2,
|
|
Clock,
|
|
Eye,
|
|
Bot,
|
|
User,
|
|
Timer,
|
|
MousePointer,
|
|
Mic,
|
|
Activity,
|
|
Play,
|
|
Plus,
|
|
GitBranch,
|
|
Trash2,
|
|
PlayCircle,
|
|
Square,
|
|
Loader2,
|
|
CheckCircle2,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { getWizardRosService, initWizardRosService, resetWizardRosService } from "~/lib/ros/wizard-ros-service";
|
|
|
|
/**
|
|
* PropertiesPanel
|
|
*
|
|
* Extracted modular panel for editing either:
|
|
* - Action properties (when an action is selected)
|
|
* - Step properties (when a step is selected and no action selected)
|
|
* - Empty instructional state otherwise
|
|
*
|
|
* Enhancements:
|
|
* - Boolean parameters render as Switch
|
|
* - Number parameters with min/max render as Slider (with live value)
|
|
* - Number parameters without bounds fall back to numeric input
|
|
* - Select and text remain standard controls
|
|
* - Provenance + category badges retained
|
|
*/
|
|
|
|
export interface PropertiesPanelProps {
|
|
design: ExperimentDesign;
|
|
selectedStep?: ExperimentStep;
|
|
selectedAction?: ExperimentAction;
|
|
onActionUpdate: (
|
|
stepId: string,
|
|
actionId: string,
|
|
updates: Partial<ExperimentAction>,
|
|
) => void;
|
|
onStepUpdate: (stepId: string, updates: Partial<ExperimentStep>) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function PropertiesPanelBase({
|
|
design,
|
|
selectedStep,
|
|
selectedAction,
|
|
onActionUpdate,
|
|
onStepUpdate,
|
|
className,
|
|
}: PropertiesPanelProps) {
|
|
const registry = actionRegistry;
|
|
|
|
// Local state for controlled inputs
|
|
const [localActionName, setLocalActionName] = useState("");
|
|
const [localStepName, setLocalStepName] = useState("");
|
|
const [localStepDescription, setLocalStepDescription] = useState("");
|
|
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
|
|
|
|
// Test action state
|
|
const [isTesting, setIsTesting] = useState(false);
|
|
const [testStatus, setTestStatus] = useState<"idle" | "running" | "success" | "error">("idle");
|
|
|
|
// Debounce timers
|
|
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
const paramUpdateTimers = useRef(new Map<string, NodeJS.Timeout>());
|
|
|
|
// Sync local state when selection ID changes (not on every object recreation)
|
|
useEffect(() => {
|
|
if (selectedAction) {
|
|
setLocalActionName(selectedAction.name);
|
|
setLocalParams(selectedAction.parameters);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedAction?.id]);
|
|
|
|
useEffect(() => {
|
|
if (selectedStep) {
|
|
setLocalStepName(selectedStep.name);
|
|
setLocalStepDescription(selectedStep.description ?? "");
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedStep?.id]);
|
|
|
|
// Cleanup timers on unmount
|
|
useEffect(() => {
|
|
const timersMap = paramUpdateTimers.current;
|
|
return () => {
|
|
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
|
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
|
timersMap.forEach((timer) => clearTimeout(timer));
|
|
};
|
|
}, []);
|
|
|
|
// Debounced update handlers
|
|
const debouncedActionUpdate = useCallback(
|
|
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
|
|
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
|
|
actionUpdateTimer.current = setTimeout(() => {
|
|
onActionUpdate(stepId, actionId, updates);
|
|
}, 300);
|
|
},
|
|
[onActionUpdate],
|
|
);
|
|
|
|
const debouncedStepUpdate = useCallback(
|
|
(stepId: string, updates: Partial<ExperimentStep>) => {
|
|
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
|
|
stepUpdateTimer.current = setTimeout(() => {
|
|
onStepUpdate(stepId, updates);
|
|
}, 300);
|
|
},
|
|
[onStepUpdate],
|
|
);
|
|
|
|
const debouncedParamUpdate = useCallback(
|
|
(stepId: string, actionId: string, paramId: string, value: unknown) => {
|
|
const existing = paramUpdateTimers.current.get(paramId);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
const timer = setTimeout(() => {
|
|
onActionUpdate(stepId, actionId, {
|
|
parameters: {
|
|
...selectedAction?.parameters,
|
|
[paramId]: value,
|
|
},
|
|
});
|
|
paramUpdateTimers.current.delete(paramId);
|
|
}, 300);
|
|
|
|
paramUpdateTimers.current.set(paramId, timer);
|
|
},
|
|
[onActionUpdate, selectedAction?.parameters],
|
|
);
|
|
|
|
// Find containing step for selected action (if any)
|
|
const containingStep =
|
|
selectedAction &&
|
|
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
|
|
|
|
// Test action handler
|
|
const handleTestAction = useCallback(async () => {
|
|
if (!selectedAction || !containingStep) return;
|
|
|
|
setIsTesting(true);
|
|
setTestStatus("running");
|
|
|
|
try {
|
|
console.log("[Test Action] Starting test for action:", selectedAction.name, selectedAction.type);
|
|
console.log("[Test Action] Execution config:", JSON.stringify(selectedAction.execution, null, 2));
|
|
console.log("[Test Action] Parameters:", selectedAction.parameters);
|
|
|
|
// Reset service to ensure clean state for testing
|
|
resetWizardRosService();
|
|
|
|
// Initialize with actual robot connection (not simulation)
|
|
const rosService = await initWizardRosService(false);
|
|
console.log("[Test Action] ROS service initialized, connected:", rosService.getConnectionStatus());
|
|
|
|
// Build action config from execution descriptor
|
|
const execution = selectedAction.execution;
|
|
let actionConfig: {
|
|
topic: string;
|
|
messageType: string;
|
|
payloadMapping: {
|
|
type: string;
|
|
payload?: Record<string, unknown>;
|
|
transformFn?: string;
|
|
};
|
|
} | undefined;
|
|
|
|
if (execution?.transport === "ros2" && execution.ros2) {
|
|
const ros2 = execution.ros2 as any;
|
|
actionConfig = {
|
|
topic: ros2.topic || "/speech",
|
|
messageType: ros2.messageType || "std_msgs/msg/String",
|
|
payloadMapping: {
|
|
type: ros2.payloadMapping?.type || "static",
|
|
payload: ros2.payloadMapping?.payload,
|
|
transformFn: ros2.payloadMapping?.transformFn,
|
|
},
|
|
};
|
|
console.log("[Test Action] Action config built:", JSON.stringify(actionConfig, null, 2));
|
|
}
|
|
|
|
// Execute the action on the real robot
|
|
const result = await rosService.executeRobotAction(
|
|
selectedAction.source?.kind === "plugin" ? (selectedAction.source?.pluginId || "core") : "core",
|
|
selectedAction.type,
|
|
selectedAction.parameters,
|
|
actionConfig,
|
|
);
|
|
console.log("[Test Action] Execution result:", result);
|
|
|
|
setTestStatus("success");
|
|
toast.success(`Action "${selectedAction.name}" executed on robot`);
|
|
} catch (error) {
|
|
setTestStatus("error");
|
|
const message = error instanceof Error ? error.message : "Action execution failed";
|
|
toast.error(message);
|
|
console.error("Test action error:", error);
|
|
} finally {
|
|
setIsTesting(false);
|
|
// Reset status after a delay
|
|
setTimeout(() => setTestStatus("idle"), 2000);
|
|
}
|
|
}, [selectedAction, containingStep]);
|
|
|
|
/* -------------------------- Action Properties View -------------------------- */
|
|
if (selectedAction && containingStep) {
|
|
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",
|
|
control: "bg-amber-500",
|
|
observation: "bg-purple-500",
|
|
} as const;
|
|
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
|
|
|
// Icon resolution uses statically imported lucide icons (no dynamic require)
|
|
const iconComponents: Record<
|
|
string,
|
|
React.ComponentType<{ className?: string }>
|
|
> = {
|
|
Zap,
|
|
MessageSquare,
|
|
Hand,
|
|
Navigation,
|
|
Volume2,
|
|
Clock,
|
|
Eye,
|
|
Bot,
|
|
User,
|
|
Timer,
|
|
MousePointer,
|
|
Mic,
|
|
Activity,
|
|
Play,
|
|
};
|
|
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
|
def?.icon && iconComponents[def.icon]
|
|
? (iconComponents[def.icon] as React.ComponentType<{
|
|
className?: string;
|
|
}>)
|
|
: Zap;
|
|
|
|
return (
|
|
<div
|
|
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
|
id="tour-designer-properties"
|
|
>
|
|
{/* Header / Metadata */}
|
|
<div className="border-b pb-3">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
{def && (
|
|
<div
|
|
className={cn(
|
|
"flex h-6 w-6 items-center justify-center rounded text-white",
|
|
categoryColors[def.category],
|
|
)}
|
|
>
|
|
<ResolvedIcon className="h-3 w-3" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-sm font-medium">
|
|
{selectedAction.name}
|
|
</h3>
|
|
<p className="text-muted-foreground text-xs capitalize">
|
|
{def?.category}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
|
|
</Badge>
|
|
{/* internal plugin identifiers hidden from UI */}
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
{selectedAction.execution?.transport}
|
|
</Badge>
|
|
{selectedAction.execution?.retryable && (
|
|
<Badge variant="outline" className="h-4 text-[10px]">
|
|
retryable
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{def?.description && (
|
|
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
|
|
{def.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Test Action Button */}
|
|
{selectedAction.execution?.transport !== "internal" && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full gap-1.5"
|
|
onClick={handleTestAction}
|
|
disabled={isTesting}
|
|
>
|
|
{testStatus === "running" ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Running...
|
|
</>
|
|
) : testStatus === "success" ? (
|
|
<>
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
Success!
|
|
</>
|
|
) : testStatus === "error" ? (
|
|
<>
|
|
<XCircle className="h-4 w-4 text-red-500" />
|
|
Failed
|
|
</>
|
|
) : (
|
|
<>
|
|
<PlayCircle className="h-4 w-4" />
|
|
Test Action
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* General */}
|
|
<div className="space-y-2">
|
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
|
General
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Display Name</Label>
|
|
<Input
|
|
value={localActionName}
|
|
onChange={(e) => {
|
|
const newName = e.target.value;
|
|
setLocalActionName(newName);
|
|
debouncedActionUpdate(containingStep.id, selectedAction.id, {
|
|
name: newName,
|
|
});
|
|
}}
|
|
onBlur={() => {
|
|
if (localActionName !== selectedAction.name) {
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
name: localActionName,
|
|
});
|
|
}
|
|
}}
|
|
className="mt-1 h-7 w-full text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Branching Configuration (Special Case) */}
|
|
{selectedAction.type === "branch" ? (
|
|
<div className="space-y-3">
|
|
<div className="text-muted-foreground flex items-center justify-between text-[10px] tracking-wide uppercase">
|
|
<span>Branch Options</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
onClick={() => {
|
|
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: newOptions,
|
|
},
|
|
},
|
|
});
|
|
// Sync to Action Params (for consistency)
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
parameters: {
|
|
...selectedAction.parameters,
|
|
options: newOptions,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{(
|
|
((containingStep.trigger.conditions as any).options as any[]) ||
|
|
[]
|
|
).map((opt: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="bg-muted/50 space-y-2 rounded border p-2"
|
|
>
|
|
<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 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,
|
|
},
|
|
},
|
|
});
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
parameters: {
|
|
...selectedAction.parameters,
|
|
options: newOpts,
|
|
},
|
|
});
|
|
}}
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<Label className="text-[10px]">Target Step</Label>
|
|
{design.steps.length <= 1 ? (
|
|
<div
|
|
className="text-muted-foreground bg-muted/50 flex h-7 items-center truncate rounded border px-2 text-[10px]"
|
|
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 };
|
|
|
|
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 w-full text-xs">
|
|
<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 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,
|
|
},
|
|
},
|
|
});
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
parameters: {
|
|
...selectedAction.parameters,
|
|
options: newOpts,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 w-[120px] text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="default">Default (Next)</SelectItem>
|
|
<SelectItem value="destructive">
|
|
Destructive (Red)
|
|
</SelectItem>
|
|
<SelectItem value="outline">Outline</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground h-6 w-6 p-0 hover:text-red-500"
|
|
onClick={() => {
|
|
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,
|
|
},
|
|
},
|
|
});
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
parameters: {
|
|
...selectedAction.parameters,
|
|
options: newOpts,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!((containingStep.trigger.conditions as any).options as any[])
|
|
?.length && (
|
|
<div className="text-muted-foreground rounded border border-dashed py-4 text-center text-xs">
|
|
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="mt-1 flex items-center gap-2">
|
|
<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="w-8 text-right font-mono text-xs">
|
|
{Number(selectedAction.parameters.iterations || 1)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : /* Standard Parameters */
|
|
def?.parameters.length ? (
|
|
<div className="space-y-3">
|
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
|
Parameters
|
|
</div>
|
|
<div className="space-y-3">
|
|
{def.parameters.map((param) => (
|
|
<ParameterEditor
|
|
key={param.id}
|
|
param={param}
|
|
value={selectedAction.parameters[param.id]}
|
|
onUpdate={(val) => {
|
|
onActionUpdate(containingStep.id, selectedAction.id, {
|
|
parameters: {
|
|
...selectedAction.parameters,
|
|
[param.id]: val,
|
|
},
|
|
});
|
|
}}
|
|
onCommit={() => {}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground text-xs">
|
|
No parameters for this action.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* --------------------------- Step Properties View --------------------------- */
|
|
if (selectedStep) {
|
|
return (
|
|
<div
|
|
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
|
id="tour-designer-properties"
|
|
>
|
|
<div className="border-b pb-2">
|
|
<h3 className="flex items-center gap-2 text-sm font-medium">
|
|
<div
|
|
className={cn("h-3 w-3 rounded", {
|
|
"bg-blue-500": selectedStep.type === "sequential",
|
|
"bg-emerald-500": selectedStep.type === "parallel",
|
|
"bg-amber-500": selectedStep.type === "conditional",
|
|
"bg-purple-500": selectedStep.type === "loop",
|
|
})}
|
|
/>
|
|
Step Settings
|
|
</h3>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
|
General
|
|
</div>
|
|
<div className="mt-2 space-y-2">
|
|
<div>
|
|
<Label className="text-xs">Name</Label>
|
|
<Input
|
|
value={localStepName}
|
|
onChange={(e) => {
|
|
const newName = e.target.value;
|
|
setLocalStepName(newName);
|
|
debouncedStepUpdate(selectedStep.id, { name: newName });
|
|
}}
|
|
onBlur={() => {
|
|
if (localStepName !== selectedStep.name) {
|
|
onStepUpdate(selectedStep.id, { name: localStepName });
|
|
}
|
|
}}
|
|
className="mt-1 h-7 w-full text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Description</Label>
|
|
<Input
|
|
value={localStepDescription}
|
|
placeholder="Optional step description"
|
|
onChange={(e) => {
|
|
const newDesc = e.target.value;
|
|
setLocalStepDescription(newDesc);
|
|
debouncedStepUpdate(selectedStep.id, {
|
|
description: newDesc,
|
|
});
|
|
}}
|
|
onBlur={() => {
|
|
if (
|
|
localStepDescription !== (selectedStep.description ?? "")
|
|
) {
|
|
onStepUpdate(selectedStep.id, {
|
|
description: localStepDescription,
|
|
});
|
|
}
|
|
}}
|
|
className="mt-1 h-7 w-full text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
|
Behavior
|
|
</div>
|
|
<div className="mt-2 space-y-2">
|
|
<div>
|
|
<Label className="text-xs">Type</Label>
|
|
<Select
|
|
value={selectedStep.type}
|
|
onValueChange={(val) => {
|
|
onStepUpdate(selectedStep.id, { type: val as StepType });
|
|
}}
|
|
disabled={true}
|
|
>
|
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sequential">Sequential</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
|
Steps always execute sequentially. Use control flow actions
|
|
for parallel/conditional logic.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Trigger</Label>
|
|
<Select
|
|
value={selectedStep.trigger.type}
|
|
onValueChange={(val) => {
|
|
onStepUpdate(selectedStep.id, {
|
|
trigger: {
|
|
...selectedStep.trigger,
|
|
type: val as TriggerType,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TRIGGER_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------- Empty State ------------------------------- */
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex h-24 items-center justify-center text-center",
|
|
className,
|
|
)}
|
|
id="tour-designer-properties"
|
|
>
|
|
<div>
|
|
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
|
|
<h3 className="mb-1 text-sm font-medium">No selection</h3>
|
|
<p className="text-muted-foreground text-xs">
|
|
Select a step or action in the flow to edit its properties.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const PropertiesPanel = React.memo(PropertiesPanelBase);
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Isolated Parameter Editor (Optimized) */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
interface ParameterEditorProps {
|
|
param: any;
|
|
value: unknown;
|
|
onUpdate: (value: unknown) => void;
|
|
onCommit: () => void;
|
|
}
|
|
|
|
const ParameterEditor = React.memo(function ParameterEditor({
|
|
param,
|
|
value: rawValue,
|
|
onUpdate,
|
|
onCommit,
|
|
}: ParameterEditorProps) {
|
|
// Local state for immediate feedback
|
|
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
|
const debounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
|
|
// Sync from prop if it changes externally
|
|
useEffect(() => {
|
|
setLocalValue(rawValue);
|
|
}, [rawValue]);
|
|
|
|
const handleUpdate = useCallback(
|
|
(newVal: unknown, immediate = false) => {
|
|
setLocalValue(newVal);
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
if (immediate) {
|
|
onUpdate(newVal);
|
|
} else {
|
|
debounceRef.current = setTimeout(() => {
|
|
onUpdate(newVal);
|
|
}, 300);
|
|
}
|
|
},
|
|
[onUpdate],
|
|
);
|
|
|
|
const handleCommit = useCallback(() => {
|
|
if (localValue !== rawValue) {
|
|
onUpdate(localValue);
|
|
}
|
|
}, [localValue, rawValue, onUpdate]);
|
|
|
|
let control: React.ReactNode = null;
|
|
|
|
if (param.type === "text") {
|
|
control = (
|
|
<textarea
|
|
value={(localValue as string) ?? ""}
|
|
placeholder={param.placeholder}
|
|
onChange={(e) => handleUpdate(e.target.value)}
|
|
onBlur={handleCommit}
|
|
rows={3}
|
|
className="mt-1 w-full rounded-md border border-input bg-transparent px-3 py-2 text-xs shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
/>
|
|
);
|
|
} else if (param.type === "select") {
|
|
control = (
|
|
<Select
|
|
value={(localValue as string) ?? ""}
|
|
onValueChange={(val) => handleUpdate(val, true)}
|
|
>
|
|
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
|
<SelectValue placeholder="Select…" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{param.options?.map((opt: string) => (
|
|
<SelectItem key={opt} value={opt}>
|
|
{opt}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
} else if (param.type === "boolean") {
|
|
control = (
|
|
<div className="mt-1 flex h-7 items-center">
|
|
<Switch
|
|
checked={Boolean(localValue)}
|
|
onCheckedChange={(val) => handleUpdate(val, true)}
|
|
aria-label={param.name}
|
|
/>
|
|
<span className="text-muted-foreground ml-2 text-[11px]">
|
|
{Boolean(localValue) ? "Enabled" : "Disabled"}
|
|
</span>
|
|
</div>
|
|
);
|
|
} else if (param.type === "number") {
|
|
const numericVal =
|
|
typeof localValue === "number" ? localValue : (param.min ?? 0);
|
|
|
|
if (param.min !== undefined || param.max !== undefined) {
|
|
const min = param.min ?? 0;
|
|
const max =
|
|
param.max ??
|
|
Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
|
const range = max - min;
|
|
const step =
|
|
param.step ??
|
|
(range <= 5
|
|
? 0.1
|
|
: range <= 50
|
|
? 0.5
|
|
: Math.max(1, Math.round(range / 100)));
|
|
|
|
control = (
|
|
<div className="mt-1">
|
|
<div className="flex items-center gap-2">
|
|
<Slider
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={[Number(numericVal)]}
|
|
onValueChange={(vals) => setLocalValue(vals[0])}
|
|
onPointerUp={() => handleUpdate(localValue)}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
value={Number(numericVal)}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
onChange={(e) => {
|
|
const v = parseFloat(e.target.value);
|
|
if (!isNaN(v)) {
|
|
setLocalValue(Math.max(min, Math.min(max, v)));
|
|
}
|
|
}}
|
|
onBlur={handleCommit}
|
|
className="h-7 w-16 text-xs tabular-nums"
|
|
/>
|
|
</div>
|
|
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
|
<span>{min}</span>
|
|
<span>{max}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
} else {
|
|
control = (
|
|
<Input
|
|
type="number"
|
|
value={numericVal}
|
|
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
|
|
onBlur={handleCommit}
|
|
className="mt-1 h-7 w-full text-xs"
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
<Label className="flex items-center gap-2 text-xs">
|
|
{param.name}
|
|
<span className="text-muted-foreground font-normal">
|
|
{param.type === "number" &&
|
|
(param.min !== undefined || param.max !== undefined) &&
|
|
typeof rawValue === "number" &&
|
|
`( ${rawValue} )`}
|
|
</span>
|
|
</Label>
|
|
{param.description && (
|
|
<div className="text-muted-foreground text-[10px]">
|
|
{param.description}
|
|
</div>
|
|
)}
|
|
{control}
|
|
</div>
|
|
);
|
|
});
|