From 1d5ba598ca981671d826bd2a272afdf9fd6ce11e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 30 Jan 2026 01:01:23 -0500 Subject: [PATCH] Improve tree editor modal UX: cancel/save and inline node naming Change 1: Add Cancel button and defer saving until Done is clicked - NodeEditorModal now uses local draft state instead of updating store directly - Cancel button discards changes; Done button commits to store - If editing a brand new node, Cancel deletes it entirely - NodeList tracks isEditingNewNode to pass to modal Change 2: Inline node naming when creating from NodePicker dropdown - Selecting "+ New Decision/Action/Solution" shows inline title input - User enters title before node is created (Enter to create, Escape to cancel) - Node appears in dropdown with human-readable title immediately Change 3: Improved dropdown labels - Format changed from "UUID (UUID...)" to "Title (UUID...)" - Untitled nodes show "Untitled Question" or "Untitled {type}" - Root node shows "Root Question (root)" when empty Co-Authored-By: Claude Opus 4.5 --- .../tree-editor/NodeEditorModal.tsx | 59 ++++- .../src/components/tree-editor/NodeList.tsx | 19 +- .../src/components/tree-editor/NodePicker.tsx | 223 +++++++++++++----- frontend/src/store/treeEditorStore.ts | 24 +- 4 files changed, 244 insertions(+), 81 deletions(-) diff --git a/frontend/src/components/tree-editor/NodeEditorModal.tsx b/frontend/src/components/tree-editor/NodeEditorModal.tsx index 3e266860..c4ad37e3 100644 --- a/frontend/src/components/tree-editor/NodeEditorModal.tsx +++ b/frontend/src/components/tree-editor/NodeEditorModal.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect, useCallback } from 'react' import { Modal } from '@/components/common/Modal' import { useTreeEditorStore } from '@/store/treeEditorStore' import { NodeFormDecision } from './NodeFormDecision' @@ -8,14 +9,39 @@ import type { TreeStructure } from '@/types' interface NodeEditorModalProps { node: TreeStructure onClose: () => void + /** If true, this is a brand new node - cancel will delete it entirely */ + isNewNode?: boolean } -export function NodeEditorModal({ node, onClose }: NodeEditorModalProps) { - const { updateNode, validationErrors } = useTreeEditorStore() +export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditorModalProps) { + const { updateNode, deleteNode, validationErrors } = useTreeEditorStore() const nodeErrors = validationErrors.filter(e => e.nodeId === node.id) - const handleUpdate = (updates: Partial) => { - updateNode(node.id, updates) + // Local draft state - changes are NOT persisted until "Done" is clicked + const [draft, setDraft] = useState(() => structuredClone(node)) + + // Reset draft when node changes (e.g., external update) + useEffect(() => { + setDraft(structuredClone(node)) + }, [node.id]) // Only reset when switching to a different node + + const handleUpdate = useCallback((updates: Partial) => { + setDraft(prev => ({ ...prev, ...updates })) + }, []) + + const handleSave = () => { + // Commit all draft changes to the store + updateNode(node.id, draft) + onClose() + } + + const handleCancel = () => { + if (isNewNode) { + // Delete the unsaved new node entirely + deleteNode(node.id) + } + // Discard changes and close (draft is just thrown away) + onClose() } const getTitle = () => { @@ -32,10 +58,17 @@ export function NodeEditorModal({ node, onClose }: NodeEditorModalProps) { } const footerContent = ( -
+
+
)} - {/* Type-specific form */} - {node.type === 'decision' && ( - + {/* Type-specific form - uses draft state, not the original node */} + {draft.type === 'decision' && ( + )} - {node.type === 'action' && ( - + {draft.type === 'action' && ( + )} - {node.type === 'solution' && ( - + {draft.type === 'solution' && ( + )} ) diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index f0bffed0..726b62cc 100644 --- a/frontend/src/components/tree-editor/NodeList.tsx +++ b/frontend/src/components/tree-editor/NodeList.tsx @@ -310,6 +310,7 @@ function NodeListItem({ export function NodeList() { const { treeStructure, addNode, deleteNode, duplicateNode, reorderNodes, findNode } = useTreeEditorStore() const [editingNodeId, setEditingNodeId] = useState(null) + const [isEditingNewNode, setIsEditingNewNode] = useState(false) const [addingToParent, setAddingToParent] = useState(null) // Get the current node from store (will update when store changes) @@ -327,8 +328,19 @@ export function NodeList() { const handleAddNode = (type: NodeType) => { const newId = addNode(addingToParent, type) setAddingToParent(null) - // Open editor for the new node + // Open editor for the new node and mark it as new setEditingNodeId(newId) + setIsEditingNewNode(true) + } + + const handleEditExistingNode = (node: TreeStructure) => { + setEditingNodeId(node.id) + setIsEditingNewNode(false) + } + + const handleCloseEditor = () => { + setEditingNodeId(null) + setIsEditingNewNode(false) } const handleDragStart = ( @@ -414,7 +426,7 @@ export function NodeList() { parentId={null} index={0} isLast={true} - onEdit={(node) => setEditingNodeId(node.id)} + onEdit={handleEditExistingNode} onDelete={deleteNode} onDuplicate={duplicateNode} onAddChild={setAddingToParent} @@ -489,7 +501,8 @@ export function NodeList() { {editingNode && ( setEditingNodeId(null)} + onClose={handleCloseEditor} + isNewNode={isEditingNewNode} /> )}
diff --git a/frontend/src/components/tree-editor/NodePicker.tsx b/frontend/src/components/tree-editor/NodePicker.tsx index e150056c..d9794bc0 100644 --- a/frontend/src/components/tree-editor/NodePicker.tsx +++ b/frontend/src/components/tree-editor/NodePicker.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState, useRef, useEffect } from 'react' import { useTreeEditorStore } from '@/store/treeEditorStore' import type { NodeType } from '@/types' import { cn } from '@/lib/utils' @@ -16,6 +16,13 @@ const NODE_TYPE_SYMBOLS: Record = { solution: '✓' // Checkmark for solution } +// Node type labels for UI +const NODE_TYPE_LABELS: Record = { + decision: 'Decision', + action: 'Action', + solution: 'Solution' +} + interface NodePickerProps { value: string onChange: (nodeId: string) => void @@ -41,9 +48,21 @@ export function NodePicker({ error, onNodeCreated }: NodePickerProps) { - const { getAvailableTargetNodes, addNode } = useTreeEditorStore() + const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore() const availableNodes = getAvailableTargetNodes(excludeNodeId) + // State for inline node creation + const [creatingNodeType, setCreatingNodeType] = useState(null) + const [newNodeTitle, setNewNodeTitle] = useState('') + const titleInputRef = useRef(null) + + // Focus the title input when creating a new node + useEffect(() => { + if (creatingNodeType && titleInputRef.current) { + titleInputRef.current.focus() + } + }, [creatingNodeType]) + // Group nodes by type const groupedNodes = useMemo(() => { const decisions = availableNodes.filter(n => n.type === 'decision') @@ -66,20 +85,54 @@ export function NodePicker({ return } - // Create the new node as a child of the parent - const newNodeId = addNode(parentNodeId, nodeType) - - // Set this new node as the selected value - onChange(newNodeId) - - // Notify parent if callback provided - onNodeCreated?.(newNodeId) + // Show inline title input instead of immediately creating + setCreatingNodeType(nodeType) + setNewNodeTitle('') } else { // Normal selection onChange(selectedValue) } } + const handleCreateNode = () => { + if (!creatingNodeType || !newNodeTitle.trim()) return + + // Create the new node as a child of the parent + const newNodeId = addNode(parentNodeId, creatingNodeType) + + // Set the title/question on the new node + if (creatingNodeType === 'decision') { + updateNode(newNodeId, { question: newNodeTitle.trim() }) + } else { + updateNode(newNodeId, { title: newNodeTitle.trim() }) + } + + // Set this new node as the selected value + onChange(newNodeId) + + // Notify parent if callback provided + onNodeCreated?.(newNodeId) + + // Reset creation state + setCreatingNodeType(null) + setNewNodeTitle('') + } + + const handleCancelCreate = () => { + setCreatingNodeType(null) + setNewNodeTitle('') + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && newNodeTitle.trim()) { + e.preventDefault() + handleCreateNode() + } else if (e.key === 'Escape') { + e.preventDefault() + handleCancelCreate() + } + } + // Find the label for the currently selected node const selectedNode = availableNodes.find(n => n.id === value) @@ -90,62 +143,110 @@ export function NodePicker({ {label} )} - setNewNodeTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={creatingNodeType === 'decision' ? 'Enter question...' : 'Enter title...'} + className={cn( + 'flex-1 rounded-md border border-input px-2 py-1 text-sm', + 'bg-background text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary' + )} + /> + +
+ + +
+ + ) : ( + <> + + {groupedNodes.actions.length > 0 && ( + + {groupedNodes.actions.map((node) => ( + + ))} + + )} - {/* Show what's selected */} - {value && selectedNode && ( -

- → {selectedNode.label} -

+ {groupedNodes.solutions.length > 0 && ( + + {groupedNodes.solutions.map((node) => ( + + ))} + + )} + + + {/* Show what's selected */} + {value && selectedNode && ( +

+ → {selectedNode.label} +

+ )} + )} {error &&

{error}

} diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts index 1c1c0e5d..5eb4c22c 100644 --- a/frontend/src/store/treeEditorStore.ts +++ b/frontend/src/store/treeEditorStore.ts @@ -657,16 +657,32 @@ export const useTreeEditorStore = create()( .filter(id => id !== excludeNodeId) .map(id => { const node = findNodeInTree(id, state.treeStructure) - let label = id let type: NodeType = 'decision' + let title = '' + if (node) { type = node.type - if (node.question) label = node.question.slice(0, 50) - else if (node.title) label = node.title.slice(0, 50) + if (node.question) title = node.question.slice(0, 50) + else if (node.title) title = node.title.slice(0, 50) } + // Use short ID format, but 'root' stays as-is const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...' - return { id, label: `${label} (${shortId})`, type } + + // Format: "Title (shortId)" or just "(shortId)" if no title + // For root without a question, show "Root Question (root)" + let label: string + if (id === 'root') { + label = title ? `${title} (root)` : 'Root Question (root)' + } else if (title) { + label = `${title} (${shortId})` + } else { + // No title yet - show placeholder with ID + const typeName = type === 'decision' ? 'Untitled Question' : `Untitled ${type}` + label = `${typeName} (${shortId})` + } + + return { id, label, type } }) } })),