* 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>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""Tree validation helper module for draft/published workflow."""
|
|
from typing import Any
|
|
|
|
PROCEDURAL_TREE_TYPES = {"procedural", "maintenance"}
|
|
|
|
|
|
class TreeValidationError(Exception):
|
|
"""Custom exception for tree validation errors."""
|
|
def __init__(self, field: str, message: str):
|
|
self.field = field
|
|
self.message = message
|
|
super().__init__(f"{field}: {message}")
|
|
|
|
|
|
# --- Troubleshooting Tree Validation ---
|
|
|
|
def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
|
|
"""Validate troubleshooting tree structure for publishing.
|
|
|
|
A valid tree for publishing must have:
|
|
- A root node with id, type, and appropriate content fields
|
|
- All decision nodes must have a question field
|
|
- All decision nodes with children must have at least 2 children
|
|
- All action nodes must have an action field
|
|
- All solution nodes must have a solution field
|
|
- No orphaned nodes (all nodes reachable from root)
|
|
|
|
Args:
|
|
tree_structure: The tree structure dict to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, list of errors)
|
|
Each error is a dict with 'field' and 'message' keys
|
|
"""
|
|
errors = []
|
|
|
|
# Check root node exists
|
|
if not tree_structure:
|
|
errors.append({"field": "tree_structure", "message": "Tree structure cannot be empty"})
|
|
return False, errors
|
|
|
|
if "id" not in tree_structure:
|
|
errors.append({"field": "tree_structure.id", "message": "Root node must have an id"})
|
|
|
|
if "type" not in tree_structure:
|
|
errors.append({"field": "tree_structure.type", "message": "Root node must have a type"})
|
|
return False, errors
|
|
|
|
# Validate root node based on type
|
|
_validate_node(tree_structure, "root", errors)
|
|
|
|
# Validate all child nodes recursively
|
|
if "children" in tree_structure:
|
|
_validate_children(tree_structure["children"], "root.children", errors)
|
|
|
|
# Block publish if any answer placeholder nodes remain
|
|
if _has_answer_nodes(tree_structure):
|
|
errors.append({
|
|
"field": "tree_structure",
|
|
"message": "Answer placeholders must be resolved to a node type before publishing."
|
|
})
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]) -> None:
|
|
"""Validate a single node in the tree structure."""
|
|
node_type = node.get("type")
|
|
|
|
if node_type == "decision":
|
|
if "question" not in node or not node["question"]:
|
|
errors.append({
|
|
"field": f"{path}.question",
|
|
"message": "Decision nodes must have a non-empty question"
|
|
})
|
|
|
|
# If node has children, must have at least 2 (for decision branches)
|
|
children = node.get("children", [])
|
|
if children and len(children) < 2:
|
|
errors.append({
|
|
"field": f"{path}.children",
|
|
"message": "Decision nodes with children must have at least 2 branches"
|
|
})
|
|
|
|
elif node_type == "action":
|
|
if "action" not in node or not node["action"]:
|
|
errors.append({
|
|
"field": f"{path}.action",
|
|
"message": "Action nodes must have a non-empty action"
|
|
})
|
|
|
|
elif node_type == "solution":
|
|
if "solution" not in node or not node["solution"]:
|
|
errors.append({
|
|
"field": f"{path}.solution",
|
|
"message": "Solution nodes must have a non-empty solution"
|
|
})
|
|
|
|
elif node_type == "answer":
|
|
# Answer nodes are draft-only placeholders — no structural validation needed
|
|
pass
|
|
|
|
else:
|
|
errors.append({
|
|
"field": f"{path}.type",
|
|
"message": f"Unknown node type: {node_type}"
|
|
})
|
|
|
|
|
|
def _validate_children(children: list[dict[str, Any]], path: str, errors: list[dict[str, str]]) -> None:
|
|
"""Recursively validate child nodes."""
|
|
for i, child in enumerate(children):
|
|
child_path = f"{path}[{i}]"
|
|
|
|
if "id" not in child:
|
|
errors.append({"field": f"{child_path}.id", "message": "Child node must have an id"})
|
|
|
|
if "type" not in child:
|
|
errors.append({"field": f"{child_path}.type", "message": "Child node must have a type"})
|
|
continue
|
|
|
|
_validate_node(child, child_path, errors)
|
|
|
|
# Recursively validate grandchildren
|
|
if "children" in child:
|
|
_validate_children(child["children"], f"{child_path}.children", errors)
|
|
|
|
|
|
def _has_answer_nodes(node: dict[str, Any]) -> bool:
|
|
"""Recursively check if any node in the tree has type 'answer'."""
|
|
if node.get("type") == "answer":
|
|
return True
|
|
for child in node.get("children", []):
|
|
if _has_answer_nodes(child):
|
|
return True
|
|
return False
|
|
|
|
|
|
# --- Procedural Tree Validation ---
|
|
|
|
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
|
|
|
|
|
def validate_procedural_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[dict[str, str]]]:
|
|
"""Validate procedural tree structure for publishing.
|
|
|
|
Procedural trees store steps as a flat ordered array in tree_structure["steps"].
|
|
|
|
Rules:
|
|
- Must have a non-empty "steps" array
|
|
- Each step must have: id, type, title
|
|
- Only procedure_step and procedure_end types allowed
|
|
- Must have exactly one procedure_end (as the last step)
|
|
- All other steps must be procedure_step
|
|
- No duplicate step IDs
|
|
- Steps with content_type must use valid values
|
|
|
|
Args:
|
|
tree_structure: Dict with a "steps" key containing the ordered step array
|
|
|
|
Returns:
|
|
Tuple of (is_valid, list of errors)
|
|
"""
|
|
errors = []
|
|
|
|
if not tree_structure:
|
|
errors.append({"field": "tree_structure", "message": "Tree structure cannot be empty"})
|
|
return False, errors
|
|
|
|
steps = tree_structure.get("steps")
|
|
if not steps or not isinstance(steps, list):
|
|
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a non-empty steps array"})
|
|
return False, errors
|
|
|
|
# Track IDs for uniqueness
|
|
seen_ids: set[str] = set()
|
|
end_count = 0
|
|
|
|
for i, step in enumerate(steps):
|
|
path = f"steps[{i}]"
|
|
|
|
# Required fields
|
|
step_id = step.get("id")
|
|
if not step_id:
|
|
errors.append({"field": f"{path}.id", "message": "Step must have an id"})
|
|
elif step_id in seen_ids:
|
|
errors.append({"field": f"{path}.id", "message": f"Duplicate step id: {step_id}"})
|
|
else:
|
|
seen_ids.add(step_id)
|
|
|
|
step_type = step.get("type")
|
|
if not step_type:
|
|
errors.append({"field": f"{path}.type", "message": "Step must have a type"})
|
|
elif step_type not in VALID_STEP_TYPES:
|
|
errors.append({"field": f"{path}.type", "message": f"Invalid step type: {step_type}. Must be one of: {', '.join(VALID_STEP_TYPES)}"})
|
|
elif step_type == "procedure_end":
|
|
end_count += 1
|
|
# procedure_end must be last step
|
|
if i != len(steps) - 1:
|
|
errors.append({"field": f"{path}.type", "message": "procedure_end must be the last step"})
|
|
|
|
if not step.get("title"):
|
|
errors.append({"field": f"{path}.title", "message": "Step must have a non-empty title"})
|
|
|
|
# Validate content_type if present
|
|
content_type = step.get("content_type")
|
|
if content_type and content_type not in VALID_CONTENT_TYPES:
|
|
errors.append({"field": f"{path}.content_type", "message": f"Invalid content_type: {content_type}. Must be one of: {', '.join(VALID_CONTENT_TYPES)}"})
|
|
|
|
# Must have exactly one end step
|
|
if end_count == 0:
|
|
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have a procedure_end step as the last step"})
|
|
elif end_count > 1:
|
|
errors.append({"field": "tree_structure.steps", "message": "Procedural tree must have exactly one procedure_end step"})
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
# --- Dispatch ---
|
|
|
|
def can_publish_tree(
|
|
tree_structure: dict[str, Any],
|
|
name: str,
|
|
description: str | None = None,
|
|
tree_type: str = "troubleshooting",
|
|
intake_form: list[dict[str, Any]] | None = None,
|
|
) -> tuple[bool, list[dict[str, str]]]:
|
|
"""Check if a tree can be published.
|
|
|
|
Dispatches to the appropriate validator based on tree_type.
|
|
|
|
Args:
|
|
tree_structure: The tree structure to validate
|
|
name: The tree name
|
|
description: Optional tree description
|
|
tree_type: 'troubleshooting' or 'procedural'
|
|
intake_form: Optional intake form fields (procedural only)
|
|
|
|
Returns:
|
|
Tuple of (can_publish, list of errors)
|
|
"""
|
|
errors = []
|
|
|
|
# Validate name
|
|
if not name or not name.strip():
|
|
errors.append({"field": "name", "message": "Tree must have a name to be published"})
|
|
|
|
# Validate structure based on tree type
|
|
if tree_type in PROCEDURAL_TREE_TYPES:
|
|
structure_valid, structure_errors = validate_procedural_structure(tree_structure)
|
|
else:
|
|
structure_valid, structure_errors = validate_tree_structure(tree_structure)
|
|
errors.extend(structure_errors)
|
|
|
|
# Validate intake form if present (procedural only)
|
|
if intake_form and tree_type in PROCEDURAL_TREE_TYPES:
|
|
form_valid, form_errors = _validate_intake_form(intake_form)
|
|
errors.extend(form_errors)
|
|
|
|
return len(errors) == 0, errors
|
|
|
|
|
|
def _validate_intake_form(intake_form: list[dict[str, Any]]) -> tuple[bool, list[dict[str, str]]]:
|
|
"""Validate intake form field definitions."""
|
|
errors = []
|
|
variable_names: set[str] = set()
|
|
|
|
for i, field in enumerate(intake_form):
|
|
path = f"intake_form[{i}]"
|
|
|
|
var_name = field.get("variable_name")
|
|
if not var_name:
|
|
errors.append({"field": f"{path}.variable_name", "message": "Field must have a variable_name"})
|
|
elif var_name in variable_names:
|
|
errors.append({"field": f"{path}.variable_name", "message": f"Duplicate variable_name: {var_name}"})
|
|
else:
|
|
variable_names.add(var_name)
|
|
|
|
if not field.get("label"):
|
|
errors.append({"field": f"{path}.label", "message": "Field must have a label"})
|
|
|
|
field_type = field.get("field_type")
|
|
if field_type in ("select", "multi_select"):
|
|
options = field.get("options")
|
|
if not options or len(options) == 0:
|
|
errors.append({"field": f"{path}.options", "message": f"{field_type} fields must have at least one option"})
|
|
|
|
return len(errors) == 0, errors
|