refactor: Update forms for inline safety, add MetadataSidePanel, update layout
- NodeFormDecision: option reorder via onUpdate (no premature store writes) - NodePicker: add allowCreate prop (default true) to hide Create New options during inline canvas editing, preventing side-effect node creation - MetadataSidePanel: 320px right slide-in overlay wrapping TreeMetadataForm, closes on backdrop click, close button, and Escape key - TreeEditorLayout: Flow mode now renders full-width TreeCanvas + MetadataSidePanel overlay; Code mode unchanged (Monaco + preview split) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
66
frontend/src/components/tree-editor/MetadataSidePanel.tsx
Normal file
66
frontend/src/components/tree-editor/MetadataSidePanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
|
||||
interface MetadataSidePanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MetadataSidePanel({ isOpen, onClose }: MetadataSidePanelProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop — click to close */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-background/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Side panel — slides in from right */}
|
||||
<div
|
||||
className="fixed right-0 top-0 z-50 flex h-full w-80 flex-col border-l border-border bg-card shadow-xl"
|
||||
role="dialog"
|
||||
aria-label="Flow metadata"
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground font-heading">
|
||||
Flow Details
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close metadata panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable metadata form */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<TreeMetadataForm />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataSidePanel
|
||||
@@ -16,7 +16,7 @@ const indexToLetter = (index: number): string => {
|
||||
}
|
||||
|
||||
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const { reorderOptions, validationErrors } = useTreeEditorStore()
|
||||
const { validationErrors } = useTreeEditorStore()
|
||||
const isRootNode = node.id === 'root'
|
||||
|
||||
const questionError = validationErrors.find(
|
||||
@@ -51,7 +51,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
}
|
||||
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
reorderOptions(node.id, fromIndex, toIndex)
|
||||
// Mutate local draft via onUpdate (backward-compatible: modal path relays to store,
|
||||
// canvas path updates local draft without writing to store early)
|
||||
const newOptions = [...(node.options || [])]
|
||||
const [moved] = newOptions.splice(fromIndex, 1)
|
||||
newOptions.splice(toIndex, 0, moved)
|
||||
onUpdate({ options: newOptions })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,9 @@ interface NodePickerProps {
|
||||
error?: string
|
||||
/** Callback when a new node is created (receives the new node ID) */
|
||||
onNodeCreated?: (nodeId: string) => void
|
||||
/** Whether to show the "Create New Node" options. Default: true.
|
||||
* Set to false in inline canvas editing to prevent premature store writes. */
|
||||
allowCreate?: boolean
|
||||
}
|
||||
|
||||
export function NodePicker({
|
||||
@@ -46,7 +49,8 @@ export function NodePicker({
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
onNodeCreated
|
||||
onNodeCreated,
|
||||
allowCreate = true
|
||||
}: NodePickerProps) {
|
||||
const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore()
|
||||
const availableNodes = getAvailableTargetNodes(excludeNodeId)
|
||||
@@ -201,12 +205,14 @@ export function NodePicker({
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
|
||||
{/* Create new options */}
|
||||
<optgroup label="Create New Node">
|
||||
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||
</optgroup>
|
||||
{/* Create new options — hidden when allowCreate=false (e.g. canvas inline editing) */}
|
||||
{allowCreate && (
|
||||
<optgroup label="Create New Node">
|
||||
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Existing nodes grouped by type */}
|
||||
{groupedNodes.decisions.length > 0 && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
import { NodeList } from './NodeList'
|
||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||
import { TreeCanvas } from './TreeCanvas'
|
||||
import { MetadataSidePanel } from './MetadataSidePanel'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -12,9 +12,15 @@ const CodeModeEditor = lazy(() =>
|
||||
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
isMetadataOpen?: boolean
|
||||
onCloseMetadata?: () => void
|
||||
}
|
||||
|
||||
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
export function TreeEditorLayout({
|
||||
isMobile = false,
|
||||
isMetadataOpen = false,
|
||||
onCloseMetadata = () => {},
|
||||
}: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
|
||||
return (
|
||||
@@ -26,7 +32,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
>
|
||||
{editorMode === 'code' ? (
|
||||
<>
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) — unchanged */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-hidden border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
@@ -50,24 +56,16 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-y-auto border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
{/* Flow Mode: Full-width visual canvas */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TreeCanvas />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-accent/50',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
{/* Metadata side panel — overlays the canvas from the right */}
|
||||
<MetadataSidePanel
|
||||
isOpen={isMetadataOpen}
|
||||
onClose={onCloseMetadata}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user