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) {
|
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||||
const { reorderOptions, validationErrors } = useTreeEditorStore()
|
const { validationErrors } = useTreeEditorStore()
|
||||||
const isRootNode = node.id === 'root'
|
const isRootNode = node.id === 'root'
|
||||||
|
|
||||||
const questionError = validationErrors.find(
|
const questionError = validationErrors.find(
|
||||||
@@ -51,7 +51,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
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 (
|
return (
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ interface NodePickerProps {
|
|||||||
error?: string
|
error?: string
|
||||||
/** Callback when a new node is created (receives the new node ID) */
|
/** Callback when a new node is created (receives the new node ID) */
|
||||||
onNodeCreated?: (nodeId: string) => void
|
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({
|
export function NodePicker({
|
||||||
@@ -46,7 +49,8 @@ export function NodePicker({
|
|||||||
className,
|
className,
|
||||||
label,
|
label,
|
||||||
error,
|
error,
|
||||||
onNodeCreated
|
onNodeCreated,
|
||||||
|
allowCreate = true
|
||||||
}: NodePickerProps) {
|
}: NodePickerProps) {
|
||||||
const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore()
|
const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore()
|
||||||
const availableNodes = getAvailableTargetNodes(excludeNodeId)
|
const availableNodes = getAvailableTargetNodes(excludeNodeId)
|
||||||
@@ -201,12 +205,14 @@ export function NodePicker({
|
|||||||
>
|
>
|
||||||
<option value="">{placeholder}</option>
|
<option value="">{placeholder}</option>
|
||||||
|
|
||||||
{/* Create new options */}
|
{/* Create new options — hidden when allowCreate=false (e.g. canvas inline editing) */}
|
||||||
|
{allowCreate && (
|
||||||
<optgroup label="Create New Node">
|
<optgroup label="Create New Node">
|
||||||
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||||
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||||
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Existing nodes grouped by type */}
|
{/* Existing nodes grouped by type */}
|
||||||
{groupedNodes.decisions.length > 0 && (
|
{groupedNodes.decisions.length > 0 && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense } from 'react'
|
||||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
|
||||||
import { NodeList } from './NodeList'
|
|
||||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||||
|
import { TreeCanvas } from './TreeCanvas'
|
||||||
|
import { MetadataSidePanel } from './MetadataSidePanel'
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -12,9 +12,15 @@ const CodeModeEditor = lazy(() =>
|
|||||||
|
|
||||||
interface TreeEditorLayoutProps {
|
interface TreeEditorLayoutProps {
|
||||||
isMobile?: boolean
|
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)
|
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +32,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
|||||||
>
|
>
|
||||||
{editorMode === 'code' ? (
|
{editorMode === 'code' ? (
|
||||||
<>
|
<>
|
||||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
{/* Code Mode: Monaco editor (60%) + Preview (40%) — unchanged */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex flex-col overflow-hidden border-border',
|
'flex flex-col overflow-hidden border-border',
|
||||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
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%) */}
|
{/* Flow Mode: Full-width visual canvas */}
|
||||||
<div className={cn(
|
<div className="flex-1 overflow-hidden">
|
||||||
'flex flex-col overflow-y-auto border-border',
|
<TreeCanvas />
|
||||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
|
||||||
)}>
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
<TreeMetadataForm />
|
|
||||||
<NodeList />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel - Preview */}
|
{/* Metadata side panel — overlays the canvas from the right */}
|
||||||
<div className={cn(
|
<MetadataSidePanel
|
||||||
'flex-1 overflow-hidden bg-accent/50',
|
isOpen={isMetadataOpen}
|
||||||
isMobile ? 'hidden' : 'block'
|
onClose={onCloseMetadata}
|
||||||
)}>
|
/>
|
||||||
<TreePreviewPanel />
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user