mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ActionDefinition, ExperimentAction } from "~/lib/experiment-designer/types";
|
||||
import type {
|
||||
ActionDefinition,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
||||
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
||||
|
||||
@@ -56,7 +59,9 @@ export class ActionRegistry {
|
||||
this.registerPluginDefinition(corePluginDef);
|
||||
this.registerPluginDefinition(wozPluginDef);
|
||||
|
||||
console.log(`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`);
|
||||
console.log(
|
||||
`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`,
|
||||
);
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
@@ -64,10 +69,7 @@ export class ActionRegistry {
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: any[],
|
||||
): void {
|
||||
loadPluginActions(studyId: string, studyPlugins: any[]): void {
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
@@ -78,7 +80,7 @@ export class ActionRegistry {
|
||||
|
||||
(studyPlugins ?? []).forEach((plugin) => {
|
||||
this.registerPluginDefinition(plugin);
|
||||
totalActionsLoaded += (plugin.actionDefinitions?.length || 0);
|
||||
totalActionsLoaded += plugin.actionDefinitions?.length || 0;
|
||||
});
|
||||
|
||||
console.log(
|
||||
@@ -114,41 +116,41 @@ export class ActionRegistry {
|
||||
// Default category based on plugin type or explicit category
|
||||
let category = categoryMap[rawCategory];
|
||||
if (!category) {
|
||||
if (plugin.id === 'hristudio-woz') category = 'wizard';
|
||||
else if (plugin.id === 'hristudio-core') category = 'control';
|
||||
else category = 'robot';
|
||||
if (plugin.id === "hristudio-woz") category = "wizard";
|
||||
else if (plugin.id === "hristudio-core") category = "control";
|
||||
else category = "robot";
|
||||
}
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
ros2: {
|
||||
topic: action.ros2.topic,
|
||||
messageType: action.ros2.messageType,
|
||||
service: action.ros2.service,
|
||||
action: action.ros2.action,
|
||||
qos: action.ros2.qos,
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
rest: {
|
||||
method: action.rest.method,
|
||||
path: action.rest.path,
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
: {
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
|
||||
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
|
||||
@@ -184,7 +186,7 @@ export class ActionRegistry {
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
nestable: action.nestable
|
||||
nestable: action.nestable,
|
||||
};
|
||||
|
||||
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
||||
@@ -193,7 +195,9 @@ export class ActionRegistry {
|
||||
}
|
||||
|
||||
// Register aliases
|
||||
const aliases = Array.isArray(action.aliases) ? action.aliases : undefined;
|
||||
const aliases = Array.isArray(action.aliases)
|
||||
? action.aliases
|
||||
: undefined;
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias === "string" && alias.trim()) {
|
||||
@@ -224,7 +228,8 @@ export class ActionRegistry {
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" = "text";
|
||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" =
|
||||
"text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
@@ -259,7 +264,10 @@ export class ActionRegistry {
|
||||
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
||||
const idsToDelete: string[] = [];
|
||||
this.actions.forEach((action, id) => {
|
||||
if (action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")) {
|
||||
if (
|
||||
action.source.kind === "plugin" &&
|
||||
!this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")
|
||||
) {
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PanelRightOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Settings
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -134,31 +134,43 @@ interface RawExperiment {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
console.log('[adaptExistingDesign] Entry - exp.steps:', exp.steps);
|
||||
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);
|
||||
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;
|
||||
let dbSteps: ExperimentStep[];
|
||||
|
||||
if (firstStep && typeof firstStep === 'object' && 'trigger' in firstStep) {
|
||||
if (
|
||||
firstStep &&
|
||||
typeof firstStep === "object" &&
|
||||
"trigger" in firstStep
|
||||
) {
|
||||
// Already converted by server
|
||||
dbSteps = exp.steps as ExperimentStep[];
|
||||
} else {
|
||||
// Raw DB steps, need conversion
|
||||
console.log('[adaptExistingDesign] Taking raw DB conversion path');
|
||||
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);
|
||||
if (
|
||||
["sequence", "parallel", "loop", "branch"].includes(action.type)
|
||||
) {
|
||||
console.log(
|
||||
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
|
||||
action.children,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -173,7 +185,10 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:', err);
|
||||
console.warn(
|
||||
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +265,7 @@ export function DesignerRoot({
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
gcTime: 0, // Garbage collect immediately
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
@@ -381,18 +396,23 @@ export function DesignerRoot({
|
||||
} | null>(null);
|
||||
|
||||
const [activeSortableItem, setActiveSortableItem] = useState<{
|
||||
type: 'step' | 'action';
|
||||
type: "step" | "action";
|
||||
data: any;
|
||||
} | null>(null);
|
||||
|
||||
/* ----------------------------- Initialization ---------------------------- */
|
||||
useEffect(() => {
|
||||
console.log('[DesignerRoot] useEffect triggered', { initialized, loadingExperiment, hasExperiment: !!experiment, hasInitialDesign: !!initialDesign });
|
||||
console.log("[DesignerRoot] useEffect triggered", {
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
hasExperiment: !!experiment,
|
||||
hasInitialDesign: !!initialDesign,
|
||||
});
|
||||
|
||||
if (initialized) return;
|
||||
if (loadingExperiment && !initialDesign) return;
|
||||
|
||||
console.log('[DesignerRoot] Proceeding with initialization');
|
||||
console.log("[DesignerRoot] Proceeding with initialization");
|
||||
|
||||
const adapted =
|
||||
initialDesign ??
|
||||
@@ -486,7 +506,6 @@ export function DesignerRoot({
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [steps, initialized, recomputeHash]);
|
||||
|
||||
|
||||
/* ----------------------------- Derived State ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
@@ -539,20 +558,30 @@ export function DesignerRoot({
|
||||
// Debug: Improved structured logging for validation results
|
||||
console.group("🧪 Experiment Validation Results");
|
||||
if (result.valid) {
|
||||
console.log(`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`, "color: green; font-weight: bold; font-size: 12px;");
|
||||
console.log(
|
||||
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
|
||||
"color: green; font-weight: bold; font-size: 12px;",
|
||||
);
|
||||
} else {
|
||||
console.log(`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`, "color: red; font-weight: bold; font-size: 12px;");
|
||||
console.log(
|
||||
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
|
||||
"color: red; font-weight: bold; font-size: 12px;",
|
||||
);
|
||||
}
|
||||
|
||||
if (result.issues.length > 0) {
|
||||
console.table(
|
||||
result.issues.map(i => ({
|
||||
result.issues.map((i) => ({
|
||||
Severity: i.severity.toUpperCase(),
|
||||
Category: i.category,
|
||||
Message: i.message,
|
||||
Suggest: i.suggestion,
|
||||
Location: i.actionId ? `Action ${i.actionId}` : (i.stepId ? `Step ${i.stepId}` : 'Global')
|
||||
}))
|
||||
Location: i.actionId
|
||||
? `Action ${i.actionId}`
|
||||
: i.stepId
|
||||
? `Step ${i.stepId}`
|
||||
: "Global",
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log("No issues found. Design is perfectly compliant.");
|
||||
@@ -583,7 +612,8 @@ export function DesignerRoot({
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -610,7 +640,7 @@ export function DesignerRoot({
|
||||
const persist = useCallback(async () => {
|
||||
if (!initialized) return;
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE initiated', {
|
||||
console.log("[DesignerRoot] 💾 SAVE initiated", {
|
||||
stepsCount: steps.length,
|
||||
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
currentHash: currentDesignHash?.slice(0, 16),
|
||||
@@ -625,7 +655,7 @@ export function DesignerRoot({
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log('[DesignerRoot] 💾 Sending to server...', {
|
||||
console.log("[DesignerRoot] 💾 Sending to server...", {
|
||||
experimentId,
|
||||
stepsCount: steps.length,
|
||||
version: designMeta.version,
|
||||
@@ -639,7 +669,7 @@ export function DesignerRoot({
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
|
||||
console.log('[DesignerRoot] 💾 Server save successful');
|
||||
console.log("[DesignerRoot] 💾 Server save successful");
|
||||
|
||||
// NOTE: We do NOT refetch here because it would reset the local steps state
|
||||
// to the server state, which would cause the hash to match the persisted hash,
|
||||
@@ -649,7 +679,7 @@ export function DesignerRoot({
|
||||
// Recompute hash and update persisted hash
|
||||
const hashResult = await recomputeHash();
|
||||
if (hashResult?.designHash) {
|
||||
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
|
||||
console.log("[DesignerRoot] 💾 Updated persisted hash:", {
|
||||
newPersistedHash: hashResult.designHash.slice(0, 16),
|
||||
fullHash: hashResult.designHash,
|
||||
});
|
||||
@@ -662,7 +692,7 @@ export function DesignerRoot({
|
||||
// Auto-validate after save to clear "Modified" (drift) status
|
||||
void validateDesign();
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||
console.log("[DesignerRoot] 💾 SAVE complete");
|
||||
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
@@ -673,7 +703,7 @@ export function DesignerRoot({
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DesignerRoot] 💾 SAVE failed:', error);
|
||||
console.error("[DesignerRoot] 💾 SAVE failed:", error);
|
||||
// Error already handled by mutation onError
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -729,7 +759,8 @@ export function DesignerRoot({
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -801,10 +832,7 @@ export function DesignerRoot({
|
||||
|
||||
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
||||
|
||||
if (
|
||||
activeId.startsWith("action-") &&
|
||||
activeData?.action
|
||||
) {
|
||||
if (activeId.startsWith("action-") && activeData?.action) {
|
||||
const a = activeData.action as {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -822,14 +850,17 @@ export function DesignerRoot({
|
||||
} else if (activeId.startsWith("s-step-")) {
|
||||
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
||||
setActiveSortableItem({
|
||||
type: 'step',
|
||||
data: activeData
|
||||
type: "step",
|
||||
data: activeData,
|
||||
});
|
||||
} else if (activeId.startsWith("s-act-")) {
|
||||
console.log("[DesignerRoot] Setting active sortable ACTION", activeData);
|
||||
console.log(
|
||||
"[DesignerRoot] Setting active sortable ACTION",
|
||||
activeData,
|
||||
);
|
||||
setActiveSortableItem({
|
||||
type: 'action',
|
||||
data: activeData
|
||||
type: "action",
|
||||
data: activeData,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -856,8 +887,6 @@ export function DesignerRoot({
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const overId = over.id.toString();
|
||||
const activeDef = active.data.current?.action;
|
||||
|
||||
@@ -892,10 +921,10 @@ export function DesignerRoot({
|
||||
// Let's assume index 0 for now (prepend) or implement lookup.
|
||||
// Better: lookup action -> children length.
|
||||
const actionId = parentId;
|
||||
const step = store.steps.find(s => s.id === stepId);
|
||||
const step = store.steps.find((s) => s.id === stepId);
|
||||
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
||||
// Actually, `store.steps` is available.
|
||||
// We can implement a quick BFS/DFS or just assume 0.
|
||||
// We can implement a quick BFS/DFS or just assume 0.
|
||||
// If dragging over the container *background* (empty space), append is usually expected.
|
||||
// Let's try 9999?
|
||||
index = 9999;
|
||||
@@ -907,7 +936,6 @@ export function DesignerRoot({
|
||||
: overId.slice("step-".length);
|
||||
const step = store.steps.find((s) => s.id === stepId);
|
||||
index = step ? step.actions.length : 0;
|
||||
|
||||
} else if (overId === "projection-placeholder") {
|
||||
// Hovering over our own projection placeholder -> keep current state
|
||||
return;
|
||||
@@ -969,13 +997,19 @@ export function DesignerRoot({
|
||||
if (activeId.startsWith("s-step-")) {
|
||||
const overId = over.id.toString();
|
||||
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
||||
if (!overId.startsWith("s-step-") && !overId.startsWith("step-")) return;
|
||||
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
|
||||
return;
|
||||
|
||||
// Strip prefixes to get raw IDs
|
||||
const rawActiveId = activeId.replace(/^s-step-/, "");
|
||||
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
||||
|
||||
console.log("[DesignerRoot] DragEnd - Step Sort", { activeId, overId, rawActiveId, rawOverId });
|
||||
console.log("[DesignerRoot] DragEnd - Step Sort", {
|
||||
activeId,
|
||||
overId,
|
||||
rawActiveId,
|
||||
rawOverId,
|
||||
});
|
||||
|
||||
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
||||
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
||||
@@ -1020,7 +1054,10 @@ export function DesignerRoot({
|
||||
if (!targetStep) return;
|
||||
|
||||
// 2. Instantiate Action
|
||||
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
active.data.current?.action
|
||||
) {
|
||||
const actionDef = active.data.current.action as {
|
||||
id: string; // type
|
||||
type: string;
|
||||
@@ -1044,13 +1081,13 @@ export function DesignerRoot({
|
||||
|
||||
const execution: ExperimentAction["execution"] =
|
||||
actionDef.execution &&
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
? {
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
@@ -1061,12 +1098,14 @@ export function DesignerRoot({
|
||||
category: actionDef.category as any,
|
||||
description: "",
|
||||
parameters: defaultParams,
|
||||
source: actionDef.source ? {
|
||||
kind: actionDef.source.kind as any,
|
||||
pluginId: actionDef.source.pluginId,
|
||||
pluginVersion: actionDef.source.pluginVersion,
|
||||
baseActionId: actionDef.id
|
||||
} : { kind: "core" },
|
||||
source: actionDef.source
|
||||
? {
|
||||
kind: actionDef.source.kind as any,
|
||||
pluginId: actionDef.source.pluginId,
|
||||
pluginVersion: actionDef.source.pluginVersion,
|
||||
baseActionId: actionDef.id,
|
||||
}
|
||||
: { kind: "core" },
|
||||
execution,
|
||||
children: [],
|
||||
};
|
||||
@@ -1080,13 +1119,25 @@ export function DesignerRoot({
|
||||
void recomputeHash();
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock, reorderStep],
|
||||
[
|
||||
steps,
|
||||
upsertAction,
|
||||
selectAction,
|
||||
recomputeHash,
|
||||
toggleLibraryScrollLock,
|
||||
reorderStep,
|
||||
],
|
||||
);
|
||||
// validation status badges removed (unused)
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
const leftPanel = useMemo(
|
||||
() => (
|
||||
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
|
||||
<div
|
||||
id="tour-designer-blocks"
|
||||
ref={libraryRootRef}
|
||||
data-library-root
|
||||
className="h-full"
|
||||
>
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
),
|
||||
@@ -1167,10 +1218,10 @@ export function DesignerRoot({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden bg-background">
|
||||
<div className="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
||||
{/* 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" />
|
||||
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
|
||||
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description={designMeta.description || "No description"}
|
||||
@@ -1181,7 +1232,7 @@ export function DesignerRoot({
|
||||
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
<div className="flex-1 min-h-0 w-full px-2 overflow-hidden">
|
||||
<div className="min-h-0 w-full flex-1 overflow-hidden px-2">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -1190,14 +1241,16 @@ export function DesignerRoot({
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<div className="grid grid-cols-8 gap-4 h-full w-full transition-all duration-300 ease-in-out">
|
||||
<div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
|
||||
{/* Left Panel (Library) */}
|
||||
{!leftCollapsed && (
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
rightCollapsed ? "col-span-3" : "col-span-2"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
rightCollapsed ? "col-span-3" : "col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Action Library</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1208,26 +1261,31 @@ export function DesignerRoot({
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||
{leftPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center Panel (Workspace) */}
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
leftCollapsed && rightCollapsed ? "col-span-8" :
|
||||
leftCollapsed ? "col-span-6" :
|
||||
rightCollapsed ? "col-span-5" :
|
||||
"col-span-4"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
leftCollapsed && rightCollapsed
|
||||
? "col-span-8"
|
||||
: leftCollapsed
|
||||
? "col-span-6"
|
||||
: rightCollapsed
|
||||
? "col-span-5"
|
||||
: "col-span-4",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
{leftCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 mr-2"
|
||||
className="mr-2 h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="Open Library"
|
||||
>
|
||||
@@ -1237,14 +1295,19 @@ export function DesignerRoot({
|
||||
<span className="text-sm font-medium">Flow Workspace</span>
|
||||
{rightCollapsed && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => startTour('designer')}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => startTour("designer")}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-2"
|
||||
className="ml-2 h-6 w-6"
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Inspector"
|
||||
>
|
||||
@@ -1254,7 +1317,7 @@ export function DesignerRoot({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{centerPanel}
|
||||
</div>
|
||||
<div className="border-t">
|
||||
@@ -1273,11 +1336,13 @@ export function DesignerRoot({
|
||||
|
||||
{/* Right Panel (Inspector) */}
|
||||
{!rightCollapsed && (
|
||||
<div className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
leftCollapsed ? "col-span-2" : "col-span-2"
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/30">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
leftCollapsed ? "col-span-2" : "col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Inspector</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1288,7 +1353,7 @@ export function DesignerRoot({
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden min-h-0 bg-muted/10">
|
||||
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1298,35 +1363,38 @@ export function DesignerRoot({
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragOverlayAction ? (
|
||||
// Library Item Drag
|
||||
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none ring-2 ring-blue-500/20">
|
||||
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded text-white",
|
||||
dragOverlayAction.category === "robot" && "bg-emerald-600",
|
||||
dragOverlayAction.category === "control" && "bg-amber-500",
|
||||
dragOverlayAction.category === "observation" &&
|
||||
"bg-purple-600",
|
||||
"bg-purple-600",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
</div>
|
||||
) : activeSortableItem?.type === 'action' ? (
|
||||
) : activeSortableItem?.type === "action" ? (
|
||||
// Existing Action Sort
|
||||
<div className="w-[300px] opacity-90 pointer-events-none">
|
||||
<div className="pointer-events-none w-[300px] opacity-90">
|
||||
<SortableActionChip
|
||||
stepId={activeSortableItem.data.stepId}
|
||||
action={activeSortableItem.data.action}
|
||||
parentId={activeSortableItem.data.parentId}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={() => { }}
|
||||
onDeleteAction={() => { }}
|
||||
onSelectAction={() => {}}
|
||||
onDeleteAction={() => {}}
|
||||
dragHandle={true}
|
||||
/>
|
||||
</div>
|
||||
) : activeSortableItem?.type === 'step' ? (
|
||||
) : activeSortableItem?.type === "step" ? (
|
||||
// Existing Step Sort
|
||||
<div className="w-[400px] pointer-events-none opacity-90">
|
||||
<StepCardPreview step={activeSortableItem.data.step} dragHandle />
|
||||
<div className="pointer-events-none w-[400px] opacity-90">
|
||||
<StepCardPreview
|
||||
step={activeSortableItem.data.step}
|
||||
dragHandle
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
@@ -173,8 +173,8 @@ export function PropertiesPanelBase({
|
||||
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 (!def && selectedAction.type.includes(".")) {
|
||||
const baseType = selectedAction.type.split(".").pop();
|
||||
if (baseType) {
|
||||
def = registry.getAction(baseType);
|
||||
}
|
||||
@@ -187,9 +187,9 @@ export function PropertiesPanelBase({
|
||||
type: selectedAction.type,
|
||||
name: selectedAction.name,
|
||||
description: `Action type: ${selectedAction.type}`,
|
||||
category: selectedAction.category || 'control',
|
||||
icon: 'Zap',
|
||||
color: '#6366f1',
|
||||
category: selectedAction.category || "control",
|
||||
icon: "Zap",
|
||||
color: "#6366f1",
|
||||
parameters: [],
|
||||
source: selectedAction.source,
|
||||
};
|
||||
@@ -225,12 +225,15 @@ export function PropertiesPanelBase({
|
||||
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
||||
def?.icon && iconComponents[def.icon]
|
||||
? (iconComponents[def.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>)
|
||||
className?: string;
|
||||
}>)
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
||||
<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">
|
||||
@@ -305,17 +308,23 @@ export function PropertiesPanelBase({
|
||||
{/* Branching Configuration (Special Case) */}
|
||||
{selectedAction.type === "branch" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase flex justify-between items-center">
|
||||
<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 currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOptions = [
|
||||
...currentOptions,
|
||||
{ label: "New Option", nextStepId: design.steps[containingStep.order + 1]?.id, variant: "default" }
|
||||
{
|
||||
label: "New Option",
|
||||
nextStepId: design.steps[containingStep.order + 1]?.id,
|
||||
variant: "default",
|
||||
},
|
||||
];
|
||||
|
||||
// Sync to Step Trigger (Source of Truth)
|
||||
@@ -324,16 +333,16 @@ export function PropertiesPanelBase({
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOptions
|
||||
}
|
||||
}
|
||||
options: newOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Sync to Action Params (for consistency)
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOptions
|
||||
}
|
||||
options: newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -342,26 +351,43 @@ export function PropertiesPanelBase({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(((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">
|
||||
{(
|
||||
((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 currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOpts = [...currentOptions];
|
||||
newOpts[idx] = { ...newOpts[idx], label: e.target.value };
|
||||
newOpts[idx] = {
|
||||
...newOpts[idx],
|
||||
label: e.target.value,
|
||||
};
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: { ...containingStep.trigger.conditions, options: newOpts }
|
||||
}
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
@@ -370,34 +396,53 @@ export function PropertiesPanelBase({
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[10px]">Target Step</Label>
|
||||
{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">
|
||||
<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 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 }
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(
|
||||
containingStep.id,
|
||||
selectedAction.id,
|
||||
{
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-full">
|
||||
<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}>
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
disabled={s.id === containingStep.id}
|
||||
>
|
||||
{s.order + 1}. {s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -410,18 +455,26 @@ export function PropertiesPanelBase({
|
||||
<Select
|
||||
value={opt.variant || "default"}
|
||||
onValueChange={(val) => {
|
||||
const currentOptions = ((containingStep.trigger.conditions as any).options as any[]) || [];
|
||||
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 }
|
||||
}
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -430,7 +483,9 @@ export function PropertiesPanelBase({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (Next)</SelectItem>
|
||||
<SelectItem value="destructive">Destructive (Red)</SelectItem>
|
||||
<SelectItem value="destructive">
|
||||
Destructive (Red)
|
||||
</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -438,20 +493,28 @@ export function PropertiesPanelBase({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-red-500"
|
||||
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 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 }
|
||||
}
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: { ...selectedAction.parameters, options: newOpts }
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -460,9 +523,12 @@ export function PropertiesPanelBase({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!(((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.
|
||||
{!((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>
|
||||
@@ -478,7 +544,7 @@ export function PropertiesPanelBase({
|
||||
{/* Iterations */}
|
||||
<div>
|
||||
<Label className="text-xs">Iterations</Label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
@@ -493,44 +559,42 @@ export function PropertiesPanelBase({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs font-mono w-8 text-right">
|
||||
<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 ? (
|
||||
) : /* 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">
|
||||
<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>
|
||||
{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 className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
No parameters for this action.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -539,7 +603,10 @@ export function PropertiesPanelBase({
|
||||
/* --------------------------- 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={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
|
||||
@@ -625,7 +692,8 @@ export function PropertiesPanelBase({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Steps always execute sequentially. Use control flow actions for parallel/conditional logic.
|
||||
Steps always execute sequentially. Use control flow actions
|
||||
for parallel/conditional logic.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -697,7 +765,7 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
param,
|
||||
value: rawValue,
|
||||
onUpdate,
|
||||
onCommit
|
||||
onCommit,
|
||||
}: ParameterEditorProps) {
|
||||
// Local state for immediate feedback
|
||||
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
||||
@@ -708,19 +776,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
setLocalValue(rawValue);
|
||||
}, [rawValue]);
|
||||
|
||||
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
|
||||
setLocalValue(newVal);
|
||||
const handleUpdate = useCallback(
|
||||
(newVal: unknown, immediate = false) => {
|
||||
setLocalValue(newVal);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (immediate) {
|
||||
onUpdate(newVal);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (immediate) {
|
||||
onUpdate(newVal);
|
||||
}, 300);
|
||||
}
|
||||
}, [onUpdate]);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdate(newVal);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
if (localValue !== rawValue) {
|
||||
@@ -772,13 +843,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||
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 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)));
|
||||
const step =
|
||||
param.step ??
|
||||
(range <= 5
|
||||
? 0.1
|
||||
: range <= 50
|
||||
? 0.5
|
||||
: Math.max(1, Math.round(range / 100)));
|
||||
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
@@ -792,7 +872,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
|
||||
{step < 1
|
||||
? Number(numericVal).toFixed(2)
|
||||
: Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
|
||||
@@ -2,52 +2,52 @@
|
||||
|
||||
import { SettingsTab } from "./tabs/SettingsTab";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
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;
|
||||
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,
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,8 +106,6 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
||||
return flattened;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Item Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -145,7 +143,7 @@ function IssueItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] leading-snug break-words whitespace-normal text-foreground">
|
||||
<p className="text-foreground text-[12px] leading-snug break-words whitespace-normal">
|
||||
{issue.message}
|
||||
</p>
|
||||
|
||||
@@ -248,8 +246,6 @@ export function ValidationPanel({
|
||||
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
||||
}, [issues, flatIssues, counts]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -289,7 +285,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "error" &&
|
||||
"bg-red-600 text-white hover:opacity-90",
|
||||
"bg-red-600 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("error")}
|
||||
aria-pressed={severityFilter === "error"}
|
||||
@@ -305,7 +301,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "warning" &&
|
||||
"bg-amber-500 text-white hover:opacity-90",
|
||||
"bg-amber-500 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("warning")}
|
||||
aria-pressed={severityFilter === "warning"}
|
||||
@@ -321,7 +317,7 @@ export function ValidationPanel({
|
||||
className={cn(
|
||||
"h-7 justify-start gap-1 text-[11px]",
|
||||
severityFilter === "info" &&
|
||||
"bg-blue-600 text-white hover:opacity-90",
|
||||
"bg-blue-600 text-white hover:opacity-90",
|
||||
)}
|
||||
onClick={() => setSeverityFilter("info")}
|
||||
aria-pressed={severityFilter === "info"}
|
||||
|
||||
@@ -5,16 +5,16 @@ 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
|
||||
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";
|
||||
@@ -24,480 +24,530 @@ 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;
|
||||
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";
|
||||
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";
|
||||
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
|
||||
// Specific Control Types
|
||||
if (action.type === "hristudio-core.wait" || action.type === "wait") {
|
||||
return {
|
||||
variant: "default",
|
||||
icon: undefined,
|
||||
bg: "bg-muted/40 hover:bg-accent/40",
|
||||
border: "border-border",
|
||||
text: "text-foreground",
|
||||
accent: "bg-muted-foreground",
|
||||
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,
|
||||
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;
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const style = getActionVisualStyle(action);
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
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-primary border-primary bg-accent/50 ring-2",
|
||||
isDragging && "scale-95 opacity-70 shadow-lg",
|
||||
isOverNested &&
|
||||
!isDragging &&
|
||||
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 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 top-0 bottom-0 left-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 min-w-0 flex-1 items-center gap-2">
|
||||
{Icon && (
|
||||
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />
|
||||
)}
|
||||
<span
|
||||
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"
|
||||
"truncate leading-snug font-medium break-words",
|
||||
style.text,
|
||||
)}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
{action.name}
|
||||
</span>
|
||||
|
||||
{/* Inline Info for Control Actions */}
|
||||
{style.variant === "wait" && !!action.parameters.duration && (
|
||||
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{String(action.parameters.duration ?? "")}s
|
||||
</span>
|
||||
)}
|
||||
{style.variant === "loop" && (
|
||||
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{String(action.parameters.iterations || 1)}x
|
||||
</span>
|
||||
)}
|
||||
{style.variant === "loop" &&
|
||||
action.parameters.requireApproval !== false && (
|
||||
<span
|
||||
className="ml-1 flex items-center gap-0.5 rounded bg-purple-500/20 px-1.5 py-0.5 font-mono text-[10px] text-purple-700 dark:text-purple-300"
|
||||
title="Requires Wizard Approval"
|
||||
>
|
||||
<HelpCircle className="h-2 w-2" />
|
||||
Ask
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validationStatus === "error" && (
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full bg-red-500 ring-1 ring-red-600"
|
||||
aria-label="Error"
|
||||
/>
|
||||
)}
|
||||
{validationStatus === "warning" && (
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
|
||||
aria-label="Warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/50 border-border/50 mr-1 flex items-center gap-0.5 rounded-md border px-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||
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="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||
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"
|
||||
>
|
||||
{/* 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)} />
|
||||
)}
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Description / Subtext */}
|
||||
{def?.description && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
|
||||
style.variant !== "default" && style.variant !== "robot" && "pl-4",
|
||||
)}
|
||||
>
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* 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 max-w-[80px] truncate rounded px-1 py-0.5 text-[9px] font-medium ring-1"
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
))}
|
||||
{def.parameters.length > 3 && (
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
+{def.parameters.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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 >
|
||||
);
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableActionChip({
|
||||
stepId,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
onReorderAction,
|
||||
dragHandle,
|
||||
isFirst,
|
||||
isLast,
|
||||
stepId,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
onReorderAction,
|
||||
dragHandle,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: ActionChipProps) {
|
||||
const isSelected = selectedActionId === action.id;
|
||||
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);
|
||||
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;
|
||||
// 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 >
|
||||
);
|
||||
const options = (currentStep.trigger as any)?.conditions?.options;
|
||||
if (
|
||||
!options?.length &&
|
||||
!(currentStep.trigger as any)?.conditions?.nextStepId
|
||||
) {
|
||||
return (
|
||||
<div className="text-muted-foreground/60 bg-background/50 mt-2 rounded border border-dashed py-2 text-center text-[10px] italic">
|
||||
No branches configured. Add options in properties.
|
||||
</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}
|
||||
// Combine explicit options and unconditional nextStepId
|
||||
// The original FlowWorkspace logic iterated options. logic there:
|
||||
// (step.trigger.conditions as any).options.map...
|
||||
|
||||
{/* 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"
|
||||
)}
|
||||
return (
|
||||
<div className="mt-2 w-full space-y-1">
|
||||
{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="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"bg-background min-w-[60px] justify-center px-1 py-0 text-[9px] font-bold tracking-wider uppercase",
|
||||
opt.variant === "destructive"
|
||||
? "border-red-500/30 text-red-600 dark:text-red-400"
|
||||
: "text-foreground border-slate-500/30",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
{opt.label}
|
||||
</Badge>
|
||||
<ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
|
||||
<span
|
||||
className="text-foreground/80 truncate font-medium"
|
||||
title={targetName}
|
||||
>
|
||||
{targetName}
|
||||
</span>
|
||||
{targetIndex !== -1 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums 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]",
|
||||
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
|
||||
)}
|
||||
>
|
||||
<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
|
||||
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
|
||||
: "bg-muted/20 dark:bg-muted/10 border-border/50",
|
||||
)}
|
||||
>
|
||||
{displayChildren?.length === 0 ? (
|
||||
<div className="text-muted-foreground/60 py-2 text-center text-[10px] 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,8 +97,12 @@ interface StepRowProps {
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
setRenamingStepId: (id: string | null) => void;
|
||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||
onReorderStep: (stepId: string, direction: 'up' | 'down') => void;
|
||||
onReorderAction?: (stepId: string, actionId: string, direction: 'up' | 'down') => void;
|
||||
onReorderStep: (stepId: string, direction: "up" | "down") => void;
|
||||
onReorderAction?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
direction: "up" | "down",
|
||||
) => void;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
@@ -157,12 +161,12 @@ function StepRow({
|
||||
ref={(el) => registerMeasureRef(step.id, el)}
|
||||
className={cn(
|
||||
"relative px-3 py-4 transition-all duration-300",
|
||||
isChild && "ml-8 pl-0"
|
||||
isChild && "ml-8 pl-0",
|
||||
)}
|
||||
data-step-id={step.id}
|
||||
>
|
||||
{isChild && (
|
||||
<div className="absolute left-[-24px] top-8 text-muted-foreground/40">
|
||||
<div className="text-muted-foreground/40 absolute top-8 left-[-24px]">
|
||||
<CornerDownRight className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
@@ -172,7 +176,7 @@ function StepRow({
|
||||
"mb-2 rounded-lg border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -220,7 +224,7 @@ function StepRow({
|
||||
onRenameStep(
|
||||
step,
|
||||
(e.target as HTMLInputElement).value.trim() ||
|
||||
step.name,
|
||||
step.name,
|
||||
);
|
||||
setRenamingStepId(null);
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -268,10 +272,10 @@ function StepRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorderStep(step.id, 'up');
|
||||
onReorderStep(step.id, "up");
|
||||
}}
|
||||
disabled={item.index === 0}
|
||||
aria-label="Move step up"
|
||||
@@ -281,58 +285,69 @@ function StepRow({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-[11px] text-muted-foreground hover:text-foreground"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorderStep(step.id, 'down');
|
||||
onReorderStep(step.id, "down");
|
||||
}}
|
||||
disabled={item.index === totalSteps - 1}
|
||||
aria-label="Move step down"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Conditional Branching Visualization */}
|
||||
|
||||
|
||||
{/* Loop Visualization */}
|
||||
{step.type === "loop" && (
|
||||
<div className="mx-3 my-3 rounded-md border text-xs" style={{
|
||||
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-info-border, #bae6fd)',
|
||||
color: 'var(--validation-info-text, #0369a1)'
|
||||
}}>
|
||||
<div
|
||||
className="mx-3 my-3 rounded-md border text-xs"
|
||||
style={{
|
||||
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-info-border, #bae6fd)",
|
||||
color: "var(--validation-info-text, #0369a1)",
|
||||
}}
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
<span>Loop Logic</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-2">
|
||||
<div className="space-y-2 p-2">
|
||||
<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
|
||||
{(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
|
||||
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">
|
||||
@@ -342,7 +357,7 @@ function StepRow({
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{displayActions.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground flex h-12 items-center justify-center rounded border border-dashed text-xs">
|
||||
Drop actions here
|
||||
</div>
|
||||
) : (
|
||||
@@ -367,7 +382,7 @@ function StepRow({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -375,15 +390,21 @@ function StepRow({
|
||||
/* Step Card Preview (for DragOverlay) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dragHandle?: boolean }) {
|
||||
export function StepCardPreview({
|
||||
step,
|
||||
dragHandle,
|
||||
}: {
|
||||
step: ExperimentStep;
|
||||
dragHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background shadow-xl ring-2 ring-blue-500/20",
|
||||
dragHandle && "cursor-grabbing"
|
||||
"bg-background rounded-lg border shadow-xl ring-2 ring-blue-500/20",
|
||||
dragHandle && "cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-2 py-1.5 p-3">
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground rounded p-1">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -401,13 +422,13 @@ export function StepCardPreview({ step, dragHandle }: { step: ExperimentStep; dr
|
||||
{step.actions.length} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
|
||||
<div className="bg-muted/10 p-2 h-12 flex items-center justify-center border-t border-dashed">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<div className="bg-muted/10 flex h-12 items-center justify-center border-t border-dashed p-2">
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{step.actions.length} actions hidden while dragging
|
||||
</span>
|
||||
</div>
|
||||
@@ -423,8 +444,6 @@ function generateStepId(): string {
|
||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sortableStepId(stepId: string) {
|
||||
return `s-step-${stepId}`;
|
||||
}
|
||||
@@ -447,7 +466,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
disabled: isStepDragging
|
||||
disabled: isStepDragging,
|
||||
});
|
||||
|
||||
if (isStepDragging) return null;
|
||||
@@ -459,14 +478,12 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
isOver &&
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* FlowWorkspace Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -520,7 +537,10 @@ export function FlowWorkspace({
|
||||
const childStepIds = useMemo(() => {
|
||||
const children = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (step.type === 'conditional' && (step.trigger.conditions as any)?.options) {
|
||||
if (
|
||||
step.type === "conditional" &&
|
||||
(step.trigger.conditions as any)?.options
|
||||
) {
|
||||
for (const opt of (step.trigger.conditions as any).options) {
|
||||
if (opt.nextStepId) {
|
||||
children.add(opt.nextStepId);
|
||||
@@ -695,26 +715,33 @@ export function FlowWorkspace({
|
||||
);
|
||||
|
||||
const handleReorderStep = useCallback(
|
||||
(stepId: string, direction: 'up' | 'down') => {
|
||||
console.log('handleReorderStep', stepId, direction);
|
||||
(stepId: string, direction: "up" | "down") => {
|
||||
console.log("handleReorderStep", stepId, direction);
|
||||
const currentIndex = steps.findIndex((s) => s.id === stepId);
|
||||
console.log('currentIndex', currentIndex, 'total', steps.length);
|
||||
console.log("currentIndex", currentIndex, "total", steps.length);
|
||||
if (currentIndex === -1) return;
|
||||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
console.log('newIndex', newIndex);
|
||||
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
console.log("newIndex", newIndex);
|
||||
if (newIndex < 0 || newIndex >= steps.length) return;
|
||||
reorderStep(currentIndex, newIndex);
|
||||
},
|
||||
[steps, reorderStep]
|
||||
[steps, reorderStep],
|
||||
);
|
||||
|
||||
const handleReorderAction = useCallback(
|
||||
(stepId: string, actionId: string, direction: 'up' | 'down') => {
|
||||
const step = steps.find(s => s.id === stepId);
|
||||
(stepId: string, actionId: string, direction: "up" | "down") => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
const findInTree = (list: ExperimentAction[], pId: string | null): { list: ExperimentAction[], parentId: string | null, index: number } | null => {
|
||||
const idx = list.findIndex(a => a.id === actionId);
|
||||
const findInTree = (
|
||||
list: ExperimentAction[],
|
||||
pId: string | null,
|
||||
): {
|
||||
list: ExperimentAction[];
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
} | null => {
|
||||
const idx = list.findIndex((a) => a.id === actionId);
|
||||
if (idx !== -1) return { list, parentId: pId, index: idx };
|
||||
|
||||
for (const a of list) {
|
||||
@@ -730,16 +757,15 @@ export function FlowWorkspace({
|
||||
if (!context) return;
|
||||
|
||||
const { parentId, index, list } = context;
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
|
||||
if (newIndex < 0 || newIndex >= list.length) return;
|
||||
|
||||
moveAction(stepId, actionId, parentId, newIndex);
|
||||
},
|
||||
[steps, moveAction]
|
||||
[steps, moveAction],
|
||||
);
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
@@ -768,9 +794,11 @@ export function FlowWorkspace({
|
||||
const overData = over.data.current;
|
||||
|
||||
if (
|
||||
activeData && overData &&
|
||||
activeData &&
|
||||
overData &&
|
||||
activeData.stepId === overData.stepId &&
|
||||
activeData.type === 'action' && overData.type === 'action'
|
||||
activeData.type === "action" &&
|
||||
overData.type === "action"
|
||||
) {
|
||||
const stepId = activeData.stepId as string;
|
||||
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
||||
@@ -809,8 +837,8 @@ export function FlowWorkspace({
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
activeData.type === 'action' &&
|
||||
overData.type === 'action'
|
||||
activeData.type === "action" &&
|
||||
overData.type === "action"
|
||||
) {
|
||||
// Fix: Access 'id' directly from data payload
|
||||
const activeActionId = activeData.id;
|
||||
@@ -825,12 +853,17 @@ export function FlowWorkspace({
|
||||
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
||||
// Determine new index
|
||||
// verification of safe move handled by store
|
||||
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
|
||||
moveAction(
|
||||
overStepId,
|
||||
activeActionId,
|
||||
overParentId,
|
||||
overData.sortable.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveAction]
|
||||
[moveAction],
|
||||
);
|
||||
|
||||
useDndMonitor({
|
||||
@@ -960,4 +993,3 @@ export function FlowWorkspace({
|
||||
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
||||
export default React.memo(FlowWorkspace);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function BottomStatusBar({
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-1.5">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Unvalidated</span>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export function BottomStatusBar({
|
||||
|
||||
const savingIndicator =
|
||||
pendingSave || saving ? (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground animate-pulse">
|
||||
<div className="text-muted-foreground flex animate-pulse items-center gap-1.5">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
@@ -117,7 +117,7 @@ export function BottomStatusBar({
|
||||
)}
|
||||
>
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{validationBadge}
|
||||
{unsavedBadge}
|
||||
{savingIndicator}
|
||||
|
||||
@@ -64,29 +64,30 @@ export interface PanelsContainerProps {
|
||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||
*/
|
||||
const Panel: React.FC<React.PropsWithChildren<{
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
contentClassName?: string;
|
||||
}>> = ({
|
||||
className: panelCls,
|
||||
panelClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out", panelCls, panelClassName)}
|
||||
const Panel: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
contentClassName?: string;
|
||||
}>
|
||||
> = ({ className: panelCls, panelClassName, contentClassName, children }) => (
|
||||
<section
|
||||
className={cn(
|
||||
"min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out",
|
||||
panelCls,
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export function PanelsContainer({
|
||||
left,
|
||||
@@ -178,7 +179,7 @@ export function PanelsContainer({
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed
|
||||
rightCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -206,7 +207,16 @@ export function PanelsContainer({
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed],
|
||||
[
|
||||
hasLeft,
|
||||
hasRight,
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
@@ -270,10 +280,10 @@ export function PanelsContainer({
|
||||
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${hasLeft ? l : 0}fr`,
|
||||
"--col-center": `${c}fr`,
|
||||
"--col-right": `${hasRight ? r : 0}fr`,
|
||||
}
|
||||
"--col-left": `${hasLeft ? l : 0}fr`,
|
||||
"--col-center": `${c}fr`,
|
||||
"--col-right": `${hasRight ? r : 0}fr`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
@@ -299,19 +309,17 @@ export function PanelsContainer({
|
||||
const centerDividers =
|
||||
showDividers && hasCenter
|
||||
? cn({
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Layout (Flex + Sheets) */}
|
||||
<div className={cn("flex flex-col h-full w-full md:hidden", className)}>
|
||||
<div className={cn("flex h-full w-full flex-col md:hidden", className)}>
|
||||
{/* Mobile Header/Toolbar for access to panels */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2 bg-background">
|
||||
<div className="bg-background flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasLeft && (
|
||||
<Sheet>
|
||||
@@ -321,9 +329,7 @@ export function PanelsContainer({
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">
|
||||
{left}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">{left}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
@@ -338,16 +344,14 @@ export function PanelsContainer({
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">
|
||||
{right}
|
||||
</div>
|
||||
<div className="h-full overflow-hidden">{right}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content (Center) */}
|
||||
<div className="flex-1 min-h-0 min-w-0 overflow-hidden relative">
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{center}
|
||||
</div>
|
||||
</div>
|
||||
@@ -357,15 +361,31 @@ export function PanelsContainer({
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"relative hidden md:grid h-full min-h-0 w-full max-w-full overflow-hidden select-none",
|
||||
"relative hidden h-full min-h-0 w-full max-w-full overflow-hidden select-none md:grid",
|
||||
// 2-3-2 ratio for left-center-right panels when all visible
|
||||
hasLeft && hasRight && !leftCollapsed && !rightCollapsed && "grid-cols-[2fr_3fr_2fr]",
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
!leftCollapsed &&
|
||||
!rightCollapsed &&
|
||||
"grid-cols-[2fr_3fr_2fr]",
|
||||
// Left collapsed: center + right (3:2 ratio)
|
||||
hasLeft && hasRight && leftCollapsed && !rightCollapsed && "grid-cols-[3fr_2fr]",
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
leftCollapsed &&
|
||||
!rightCollapsed &&
|
||||
"grid-cols-[3fr_2fr]",
|
||||
// Right collapsed: left + center (2:3 ratio)
|
||||
hasLeft && hasRight && !leftCollapsed && rightCollapsed && "grid-cols-[2fr_3fr]",
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
!leftCollapsed &&
|
||||
rightCollapsed &&
|
||||
"grid-cols-[2fr_3fr]",
|
||||
// Both collapsed: center only
|
||||
hasLeft && hasRight && leftCollapsed && rightCollapsed && "grid-cols-1",
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
leftCollapsed &&
|
||||
rightCollapsed &&
|
||||
"grid-cols-1",
|
||||
// Only left and center
|
||||
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
|
||||
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
|
||||
@@ -409,7 +429,7 @@ export function PanelsContainer({
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 bottom-0 w-1.5 -ml-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
|
||||
className="absolute top-0 bottom-0 z-50 -ml-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||
style={{ left: "var(--col-left)" }}
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
@@ -419,7 +439,7 @@ export function PanelsContainer({
|
||||
{hasRight && !rightCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 bottom-0 w-1.5 -mr-0.75 z-50 cursor-col-resize hover:bg-blue-400/50 transition-colors focus:outline-none"
|
||||
className="absolute top-0 bottom-0 z-50 -mr-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||
style={{ right: "var(--col-right)" }}
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
|
||||
@@ -89,8 +89,8 @@ function DraggableAction({
|
||||
|
||||
const style: React.CSSProperties = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Sparkles;
|
||||
@@ -174,7 +174,10 @@ export interface ActionLibraryPanelProps {
|
||||
onCollapse?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanelProps = {}) {
|
||||
export function ActionLibraryPanel({
|
||||
collapsed,
|
||||
onCollapse,
|
||||
}: ActionLibraryPanelProps = {}) {
|
||||
const registry = useActionRegistry();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -299,8 +302,6 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
||||
setShowOnlyFavorites(false);
|
||||
}, [categories]);
|
||||
|
||||
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const activeCats = selectedCategories;
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -339,7 +340,10 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
id="tour-designer-blocks"
|
||||
>
|
||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
@@ -493,4 +497,3 @@ export function ActionLibraryPanel({ collapsed, onCollapse }: ActionLibraryPanel
|
||||
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
||||
export default React.memo(ActionLibraryPanel);
|
||||
|
||||
|
||||
@@ -155,9 +155,12 @@ function projectActionForDesign(
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
|
||||
execution: action.execution
|
||||
? projectExecutionDescriptor(action.execution)
|
||||
: null,
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
|
||||
children:
|
||||
action.children?.map((c) => projectActionForDesign(c, options)) ?? [],
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
@@ -176,16 +179,16 @@ function projectExecutionDescriptor(
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -244,12 +247,14 @@ export async function computeActionSignature(
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw
|
||||
? canonicalize(def.parameterSchemaRaw)
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
return hashObject(projection);
|
||||
}
|
||||
@@ -271,29 +276,33 @@ export async function computeDesignHash(
|
||||
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
||||
|
||||
// 2. Map hierarchically (Merkle style)
|
||||
const stepHashes = await Promise.all(sortedSteps.map(async (s) => {
|
||||
// Action hashes
|
||||
const actionHashes = await Promise.all(s.actions.map(a => hashObject(projectActionForDesign(a, options))));
|
||||
const stepHashes = await Promise.all(
|
||||
sortedSteps.map(async (s) => {
|
||||
// Action hashes
|
||||
const actionHashes = await Promise.all(
|
||||
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
|
||||
);
|
||||
|
||||
// Step hash
|
||||
const pStep = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
order: s.order,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditions: canonicalize(s.trigger.conditions),
|
||||
},
|
||||
actions: actionHashes,
|
||||
...(options.includeStepNames ? { name: s.name } : {}),
|
||||
};
|
||||
return hashObject(pStep);
|
||||
}));
|
||||
// Step hash
|
||||
const pStep = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
order: s.order,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditions: canonicalize(s.trigger.conditions),
|
||||
},
|
||||
actions: actionHashes,
|
||||
...(options.includeStepNames ? { name: s.name } : {}),
|
||||
};
|
||||
return hashObject(pStep);
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Aggregate design hash
|
||||
return hashObject({
|
||||
steps: stepHashes,
|
||||
count: steps.length
|
||||
count: steps.length,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface DesignerState {
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
action: ExperimentAction;
|
||||
} | null
|
||||
} | null,
|
||||
) => void;
|
||||
|
||||
/* ------------------------------ Mutators --------------------------------- */
|
||||
@@ -109,10 +109,20 @@ export interface DesignerState {
|
||||
reorderStep: (from: number, to: number) => void;
|
||||
|
||||
// Actions
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
|
||||
upsertAction: (
|
||||
stepId: string,
|
||||
action: ExperimentAction,
|
||||
parentId?: string | null,
|
||||
index?: number,
|
||||
) => void;
|
||||
removeAction: (stepId: string, actionId: string) => void;
|
||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
|
||||
moveAction: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
newParentId: string | null,
|
||||
newIndex: number,
|
||||
) => void;
|
||||
|
||||
// Dirty
|
||||
markDirty: (id: string) => void;
|
||||
@@ -173,8 +183,7 @@ function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
}
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
return steps.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
@@ -257,298 +266,331 @@ function insertActionIntoTree(
|
||||
|
||||
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(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
insertionProjection: null,
|
||||
}) =>
|
||||
create<DesignerState>((set, get) => ({
|
||||
steps: props.initialSteps
|
||||
? reindexSteps(cloneSteps(props.initialSteps))
|
||||
: [],
|
||||
dirtyEntities: new Set<string>(),
|
||||
validationIssues: {},
|
||||
actionSignatureIndex: new Map(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
insertionProjection: null,
|
||||
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (
|
||||
stepId: string,
|
||||
action: ExperimentAction,
|
||||
parentId: string | null = null,
|
||||
index?: number,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
|
||||
// Check if exists (update)
|
||||
const exists = findActionById(s.actions, action.id);
|
||||
if (exists) {
|
||||
// If updating, we don't (currently) support moving via upsert.
|
||||
// Use moveAction for moving.
|
||||
return {
|
||||
...s,
|
||||
actions: updateActionInTree(s.actions, action),
|
||||
};
|
||||
}
|
||||
|
||||
// Add new
|
||||
// If index is provided, use it. Otherwise append.
|
||||
const insertIndex = index ?? s.actions.length;
|
||||
|
||||
// Check if exists (update)
|
||||
const exists = findActionById(s.actions, action.id);
|
||||
if (exists) {
|
||||
// If updating, we don't (currently) support moving via upsert.
|
||||
// Use moveAction for moving.
|
||||
return {
|
||||
...s,
|
||||
actions: updateActionInTree(s.actions, action)
|
||||
actions: insertActionIntoTree(
|
||||
s.actions,
|
||||
action,
|
||||
parentId,
|
||||
insertIndex,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Add new
|
||||
// If index is provided, use it. Otherwise append.
|
||||
const insertIndex = index ?? s.actions.length;
|
||||
|
||||
});
|
||||
return {
|
||||
...s,
|
||||
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: removeActionFromTree(s.actions, actionId),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: removeActionFromTree(s.actions, actionId),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
moveAction: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
newParentId: string | null,
|
||||
newIndex: number,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
|
||||
const actionToMove = findActionById(s.actions, actionId);
|
||||
if (!actionToMove) return s;
|
||||
const actionToMove = findActionById(s.actions, actionId);
|
||||
if (!actionToMove) return s;
|
||||
|
||||
const pruned = removeActionFromTree(s.actions, actionId);
|
||||
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
|
||||
return { ...s, actions: inserted };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
|
||||
};
|
||||
}),
|
||||
const pruned = removeActionFromTree(s.actions, actionId);
|
||||
const inserted = insertActionIntoTree(
|
||||
pruned,
|
||||
actionToMove,
|
||||
newParentId,
|
||||
newIndex,
|
||||
);
|
||||
return { ...s, actions: inserted };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
stepId,
|
||||
actionId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
get().moveAction(
|
||||
stepId,
|
||||
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
|
||||
null,
|
||||
to,
|
||||
), // Legacy compat support (only works for root level reorder)
|
||||
|
||||
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
|
||||
setInsertionProjection: (projection) =>
|
||||
set({ insertionProjection: projection }),
|
||||
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
export const useDesignerStore = createDesignerStore({});
|
||||
|
||||
|
||||
@@ -51,10 +51,7 @@ export interface ValidationResult {
|
||||
|
||||
// Steps should ALWAYS execute sequentially
|
||||
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"conditional",
|
||||
];
|
||||
const VALID_STEP_TYPES: StepType[] = ["sequential", "conditional"];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
|
||||
@@ -5,24 +5,30 @@ 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,
|
||||
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,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
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";
|
||||
@@ -31,264 +37,307 @@ 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),
|
||||
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;
|
||||
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 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 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"],
|
||||
},
|
||||
});
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
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>
|
||||
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 gap-6 md:grid-cols-3">
|
||||
{/* Left Column: Basic Information (Spans 2) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<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="min-h-[300px] resize-none"
|
||||
{...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-muted-foreground text-xs">
|
||||
WIP
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="testing">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Testing</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
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-muted-foreground text-xs">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="deprecated">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
Deprecated
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
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-muted-foreground mb-1 text-xs font-medium">
|
||||
Study
|
||||
</p>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Experiment ID
|
||||
</p>
|
||||
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
|
||||
{experiment.id.split("-")[0]}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Created
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{new Date(
|
||||
experiment.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{new Date(
|
||||
experiment.updatedAt,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{designStats && (
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
Statistics
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
|
||||
<span className="font-semibold">
|
||||
{designStats.stepCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Steps</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 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 border-t pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateExperiment.isPending || !isDirty}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{updateExperiment.isPending ? (
|
||||
"Saving..."
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user