fix: allow section_header step type in validation, improve tag input

- Add 'section_header' to VALID_STEP_TYPES in backend validation so
  procedural flows with section headers can be published
- Replace procedural editor's inline tag input with TagInput component
  (supports autocomplete, Tab, comma, semicolon, and paste splitting)
- Add semicolon delimiter support to TagInput component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-14 21:08:59 -05:00
parent db5d8a81c1
commit 505b1c8246
3 changed files with 25 additions and 48 deletions

View File

@@ -115,7 +115,7 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
# --- Procedural Tree Validation --- # --- Procedural Tree Validation ---
VALID_STEP_TYPES = {"procedure_step", "procedure_end"} VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"} VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}

View File

@@ -106,10 +106,14 @@ export function TagInput({
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setShowSuggestions(false) setShowSuggestions(false)
setSelectedIndex(-1) setSelectedIndex(-1)
} else if (e.key === ',' || e.key === 'Tab') { } else if (e.key === ',' || e.key === ';' || e.key === 'Tab') {
if (inputValue.trim()) { if (inputValue.trim()) {
e.preventDefault() e.preventDefault()
addTag(inputValue) // Support multiple tags separated by commas or semicolons
const parts = inputValue.split(/[,;]/).map(s => s.trim()).filter(Boolean)
for (const part of parts) {
addTag(part)
}
} }
} }
} }
@@ -157,7 +161,20 @@ export function TagInput({
ref={inputRef} ref={inputRef}
type="text" type="text"
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => {
const val = e.target.value
// If pasted text contains delimiters, split into tags immediately
if (val.includes(',') || val.includes(';')) {
const parts = val.split(/[,;]/).map(s => s.trim()).filter(Boolean)
const last = parts.pop()
for (const part of parts) {
addTag(part)
}
setInputValue(last || '')
} else {
setInputValue(val)
}
}}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={() => { onFocus={() => {
if (inputValue.length >= 1 && suggestions.length > 0) { if (inputValue.length >= 1 && suggestions.length > 0) {
@@ -221,7 +238,7 @@ export function TagInput({
{/* Helper text */} {/* Helper text */}
<p className="mt-1 text-xs text-white/40"> <p className="mt-1 text-xs text-white/40">
{tags.length}/{maxTags} tags. Press Enter or comma to add. {tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
</p> </p>
</div> </div>
) )

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react' import { useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered } from 'lucide-react' import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
import { treesApi } from '@/api/trees' import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore' import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder' import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
import { StepList } from '@/components/procedural-editor/StepList' import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
export function ProceduralEditorPage() { export function ProceduralEditorPage() {
@@ -33,8 +34,6 @@ export function ProceduralEditorPage() {
getTreeForSave, getTreeForSave,
} = useProceduralEditorStore() } = useProceduralEditorStore()
const [tagInput, setTagInput] = useState('')
// Load tree or init new // Load tree or init new
useEffect(() => { useEffect(() => {
if (isEditMode && id) { if (isEditMode && id) {
@@ -95,18 +94,6 @@ export function ProceduralEditorPage() {
} }
} }
const handleAddTag = () => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
setTags([...tags, tag])
setTagInput('')
}
}
const handleRemoveTag = (tag: string) => {
setTags(tags.filter((t) => t !== tag))
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex min-h-[50vh] items-center justify-center"> <div className="flex min-h-[50vh] items-center justify-center">
@@ -187,34 +174,7 @@ export function ProceduralEditorPage() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label> <label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
<div className="flex items-center gap-2"> <TagInput tags={tags} onChange={setTags} />
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
placeholder="Add tag..."
className="flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
{tags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
>
{tag}
<button
onClick={() => handleRemoveTag(tag)}
className="text-white/40 hover:text-white"
>
&times;
</button>
</span>
))}
</div>
)}
</div> </div>
<div className="flex items-end pb-1"> <div className="flex items-end pb-1">