feat: canvas UX fixes — scroll, fullscreen, InfoTip tooltips, answer stub system (#80)
* feat: Add TreeCanvasNode inline editor card component Replaces modal-based node editing with inline expand/collapse cards. Each card shows node type, title, and options in compact mode, then renders the full edit form inline on expand — no modal required. Local draft state with save/cancel prevents premature store writes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Add TreeCanvas layout with visual branching and orchestration Replaces NodeList + TreePreviewPanel with a single full-width canvas. Decision nodes branch horizontally; action/solution nodes flow vertically. Inline type picker adds nodes without modal interruption. Handles pending link resolution, inbound reference cleanup on delete, and selection sync. CSS dot-grid background + connector lines for structure clarity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> * feat: Wire toolbar metadata toggle and integrate canvas layout - Add isMetadataOpen state in TreeEditorPage - Add Metadata toolbar button (visible in Flow mode only) - Auto-close metadata panel when switching to Code mode - Pass isMetadataOpen/onCloseMetadata props through TreeEditorLayout - Update Flow mode toggle tooltip to reflect new canvas editing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add canvas UX fixes design doc (scroll, tooltips, answer stubs) Captures approved design for three post-implementation UX improvements to the tree canvas editor: card scroll fix, info tooltip replacement for hint text, and the new 'answer' node type for sketching decision branches before assigning types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add implementation plan for canvas UX fixes 12-task plan covering scroll fix, info tooltips, and answer stub node type. Each task has exact file paths, code, and build verification steps. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: make canvas card expanded area scrollable with sticky header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add fullscreen toggle to Modal, enable in NodeEditorModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add reusable InfoTip component for field-level help Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormResolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add 'answer' to NodeType union for branch placeholder stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AnswerStubCard component for unresolved branch placeholders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard NODE_TYPE_CONFIG lookup against 'answer' type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: redesign NodeFormDecision to label-only options, remove NodePicker Users now type answer labels only. Stub nodes are created automatically by TreeCanvas when the decision node is saved. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: auto-create answer stubs on decision save, render AnswerStubCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add answer type to all Record<NodeType> icon and color maps Fixes NodeList, ContinuationModal, NodePicker, and TreePreviewNode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow 'answer' type in tree drafts, block on publish Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: block publish if unresolved answer stub nodes exist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: serialize 'answer' stub nodes in markdown output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add defensive guard for answer nodes in session navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add delete button with confirmation to AnswerStubCard Adds an inline delete flow to answer stub placeholder cards: - Trash icon button (top-right, subtle) visible in idle state - Click reveals "Delete this stub?" confirmation with Delete/Cancel - Confirmed delete calls onDelete(nodeId) wired to handleDelete in TreeCanvas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: prevent category Cancel overflow and add Tab/Enter to create options - TreeMetadataForm: add min-w-0 + shrink-0 to keep Cancel button in-panel - NodeFormDecision: Tab or Enter on the last non-empty option input adds a new option and auto-focuses it; empty last input lets Tab pass through normally Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: re-sync draft from store when canvas card is opened When a decision node is saved with new options, stub next_node_id values are written back to the store. But the local draft was initialized once at mount and never refreshed, so reopening the card gave a stale draft with empty next_node_ids — causing duplicate stubs on every subsequent save. Fix: reset draft from the live node whenever isExpanded transitions to true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix+feat: blank options, stub card dismiss, collapsible subtrees - TreeCanvas: strip blank-label options on save so they don't generate stubs; also filter them from the unlinked-option add-button list - AnswerStubCard: collapse type-picker when clicking outside the card - TreeCanvasNode: add subtree collapse toggle button (ChevronsDownUp icon) visible in compact mode when the node has children - TreeCanvas: track collapsedNodeIds; hide subtree behind a clickable "N nodes hidden" pill when collapsed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: stop connector fork line from overlapping child cards Replace the two-element approach (separate fork line + child lanes div with mismatched maxWidth values) with a single relative-positioned container. The fork line is absolutely positioned and its left/right are calculated from the number of children so it spans exactly from the center of the first lane to the center of the last lane. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace Show Drafts checkbox with Drafts tab in Flow Library - Remove the out-of-place checkbox; add 'Drafts' as a tab alongside All | Troubleshooting | Projects | Maintenance - Drafts tab sets showDrafts=true + typeFilter='all' so the API filter still works correctly via include_drafts - Move SortDropdown to the right side next to ViewToggle, so both secondary controls are grouped together Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Play } from 'lucide-react'
|
||||
import { DynamicArrayField } from './DynamicArrayField'
|
||||
import { NodePicker } from './NodePicker'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import type { TreeStructure, TreeOption } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InfoTip } from '@/components/common/InfoTip'
|
||||
|
||||
interface NodeFormDecisionProps {
|
||||
node: TreeStructure
|
||||
@@ -16,8 +17,11 @@ const indexToLetter = (index: number): string => {
|
||||
}
|
||||
|
||||
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const { reorderOptions, validationErrors } = useTreeEditorStore()
|
||||
const { validationErrors } = useTreeEditorStore()
|
||||
const isRootNode = node.id === 'root'
|
||||
// Track input elements by index so we can focus the newly added one
|
||||
const inputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
|
||||
const shouldFocusLast = useRef(false)
|
||||
|
||||
const questionError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === 'question'
|
||||
@@ -27,6 +31,15 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
e => e.nodeId === node.id && e.field === 'options'
|
||||
)
|
||||
|
||||
// After options array grows (due to keyboard-triggered add), focus the last input
|
||||
useEffect(() => {
|
||||
if (shouldFocusLast.current) {
|
||||
shouldFocusLast.current = false
|
||||
const lastIndex = (node.options?.length ?? 1) - 1
|
||||
inputRefs.current.get(lastIndex)?.focus()
|
||||
}
|
||||
}, [node.options?.length])
|
||||
|
||||
const handleAddOption = () => {
|
||||
const newOption: TreeOption = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -38,6 +51,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
})
|
||||
}
|
||||
|
||||
// Add a new option and focus it (used by keyboard shortcut)
|
||||
const handleAddOptionAndFocus = () => {
|
||||
shouldFocusLast.current = true
|
||||
handleAddOption()
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
const newOptions = [...(node.options || [])]
|
||||
newOptions.splice(index, 1)
|
||||
@@ -51,7 +70,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 (
|
||||
@@ -81,11 +105,6 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{isRootNode && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
What's the main question to diagnose the issue?
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={node.question || ''}
|
||||
@@ -125,18 +144,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
|
||||
<InfoTip text={isRootNode
|
||||
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
|
||||
: "Each option can branch to a different next step."} />
|
||||
</label>
|
||||
{isRootNode ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Each option can branch to a different next step.
|
||||
</p>
|
||||
)}
|
||||
{optionsError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionsError.message}</p>
|
||||
)}
|
||||
@@ -152,52 +165,45 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const optionLabelError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].label`
|
||||
)
|
||||
const optionNextError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
|
||||
)
|
||||
const letter = indexToLetter(index)
|
||||
const isLastOption = index === (node.options?.length ?? 1) - 1
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-accent/50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{/* Letter badge */}
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode ? 'bg-blue-500/20 text-blue-400' : 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(index, el)
|
||||
else inputRefs.current.delete(index)
|
||||
}}
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||
placeholder={isRootNode
|
||||
? `Branch ${letter}: e.g., "Network Issues", "Application Errors"...`
|
||||
? `Branch ${letter}: e.g., "Network Issues"...`
|
||||
: `Option ${letter} label`}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Tab' || e.key === 'Enter') && isLastOption && option.label.trim()) {
|
||||
e.preventDefault()
|
||||
handleAddOptionAndFocus()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'block flex-1 rounded-md border px-3 py-2 text-sm',
|
||||
'block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
optionLabelError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{optionLabelError && (
|
||||
<p className="mb-2 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
<div className="pl-8">
|
||||
<NodePicker
|
||||
value={option.next_node_id}
|
||||
onChange={(nodeId) => handleUpdateOption(index, { next_node_id: nodeId })}
|
||||
parentNodeId={node.id}
|
||||
excludeNodeId={node.id}
|
||||
placeholder={isRootNode
|
||||
? `What happens when user selects "${option.label || `Branch ${letter}`}"?`
|
||||
: "Select or create next node..."}
|
||||
error={optionNextError?.message}
|
||||
/>
|
||||
{optionLabelError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user