Files
resolutionflow/frontend/src/components/tree-editor/NodeFormAction.tsx
chihlasm 94de29b5f2 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>
2026-02-18 12:52:08 -05:00

179 lines
6.1 KiB
TypeScript

import { useState } from 'react'
import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { InfoTip } from '@/components/common/InfoTip'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface NodeFormActionProps {
node: TreeStructure
onUpdate: (updates: Partial<TreeStructure>) => void
}
export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
const { validationErrors } = useTreeEditorStore()
const [showPreview, setShowPreview] = useState(false)
const titleError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'title'
)
const nextNodeError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'next_node_id'
)
const handleAddCommand = () => {
onUpdate({
commands: [...(node.commands || []), '']
})
}
const handleRemoveCommand = (index: number) => {
const newCommands = [...(node.commands || [])]
newCommands.splice(index, 1)
onUpdate({ commands: newCommands })
}
const handleUpdateCommand = (index: number, value: string) => {
const newCommands = [...(node.commands || [])]
newCommands[index] = value
onUpdate({ commands: newCommands })
}
const handleReorderCommands = (fromIndex: number, toIndex: number) => {
const newCommands = [...(node.commands || [])]
const [moved] = newCommands.splice(fromIndex, 1)
newCommands.splice(toIndex, 0, moved)
onUpdate({ commands: newCommands })
}
return (
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-foreground">
Title <span className="text-red-400">*</span>
</label>
<input
type="text"
value={node.title || ''}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="e.g., Restart the Service"
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-card text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
titleError ? 'border-red-400' : 'border-border'
)}
/>
{titleError && (
<p className="mt-1 text-xs text-red-400">{titleError.message}</p>
)}
</div>
{/* Description */}
<div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Description
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
</label>
{node.description && (
<button
type="button"
onClick={() => setShowPreview(!showPreview)}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{showPreview ? 'Edit' : 'Preview'}
</button>
)}
</div>
{showPreview && node.description ? (
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
<MarkdownContent content={node.description} />
</div>
) : (
<textarea
value={node.description || ''}
onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="Detailed instructions for this action...
**Example formatting:**
1. First step
2. Second step
**Note:** Important information here"
rows={5}
className={cn(
'mt-1 block w-full rounded-md border border-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'
)}
/>
)}
</div>
{/* Commands */}
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Commands
<InfoTip text="PowerShell or CLI commands to execute" />
</label>
<DynamicArrayField
items={node.commands || []}
onAdd={handleAddCommand}
onRemove={handleRemoveCommand}
onReorder={handleReorderCommands}
addLabel="Add Command"
renderItem={(command, index) => (
<input
type="text"
value={command}
onChange={(e) => handleUpdateCommand(index, e.target.value)}
placeholder="e.g., Get-Service BrokerAgent"
className={cn(
'block w-full rounded-md border border-border px-3 py-2 font-mono text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
)}
/>
</div>
{/* Expected Outcome */}
<div>
<label className="block text-sm font-medium text-foreground">
Expected Outcome
</label>
<input
type="text"
value={node.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value })}
placeholder="e.g., Service should show as Running"
className={cn(
'mt-1 block w-full rounded-md border border-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'
)}
/>
</div>
{/* Next Node */}
<NodePicker
value={node.next_node_id || ''}
onChange={(nodeId) => onUpdate({ next_node_id: nodeId })}
parentNodeId={node.id}
excludeNodeId={node.id}
label="Next Node (after action)"
placeholder="Select or create next node..."
error={nextNodeError?.message}
/>
</div>
)
}
export default NodeFormAction