diff --git a/server/routers/features.py b/server/routers/features.py index ce0f388..bc6353c 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -17,6 +17,7 @@ from ..schemas import ( FeatureCreate, FeatureListResponse, FeatureResponse, + FeatureUpdate, ) from ..utils.validation import validate_project_name @@ -217,6 +218,63 @@ async def get_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Database error occurred") +@router.patch("/{feature_id}", response_model=FeatureResponse) +async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate): + """ + Update a feature's details. + + Only features that are not yet completed (passes=False) can be edited. + This allows users to provide corrections or additional instructions + when the agent is stuck or implementing a feature incorrectly. + """ + project_name = validate_project_name(project_name) + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + + # Prevent editing completed features + if feature.passes: + raise HTTPException( + status_code=400, + detail="Cannot edit a completed feature. Features marked as done are immutable." + ) + + # Apply updates for non-None fields + if update.category is not None: + feature.category = update.category + if update.name is not None: + feature.name = update.name + if update.description is not None: + feature.description = update.description + if update.steps is not None: + feature.steps = update.steps + if update.priority is not None: + feature.priority = update.priority + + session.commit() + session.refresh(feature) + + return feature_to_response(feature) + except HTTPException: + raise + except Exception: + logger.exception("Failed to update feature") + raise HTTPException(status_code=500, detail="Failed to update feature") + + @router.delete("/{feature_id}") async def delete_feature(project_name: str, feature_id: int): """Delete a feature.""" diff --git a/server/schemas.py b/server/schemas.py index e9b9c31..968cb6f 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,6 +87,15 @@ class FeatureCreate(FeatureBase): priority: int | None = None +class FeatureUpdate(BaseModel): + """Request schema for updating a feature (partial updates allowed).""" + category: str | None = None + name: str | None = None + description: str | None = None + steps: list[str] | None = None + priority: int | None = None + + class FeatureResponse(FeatureBase): """Response schema for a feature.""" id: int diff --git a/ui/src/components/EditFeatureForm.tsx b/ui/src/components/EditFeatureForm.tsx new file mode 100644 index 0000000..2e9c5b4 --- /dev/null +++ b/ui/src/components/EditFeatureForm.tsx @@ -0,0 +1,248 @@ +import { useState, useId } from 'react' +import { X, Save, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react' +import { useUpdateFeature } from '../hooks/useProjects' +import type { Feature } from '../lib/types' + +interface Step { + id: string + value: string +} + +interface EditFeatureFormProps { + feature: Feature + projectName: string + onClose: () => void + onSaved: () => void +} + +export function EditFeatureForm({ feature, projectName, onClose, onSaved }: EditFeatureFormProps) { + const formId = useId() + const [category, setCategory] = useState(feature.category) + const [name, setName] = useState(feature.name) + const [description, setDescription] = useState(feature.description) + const [priority, setPriority] = useState(String(feature.priority)) + const [steps, setSteps] = useState(() => + feature.steps.length > 0 + ? feature.steps.map((step, i) => ({ id: `${formId}-step-${i}`, value: step })) + : [{ id: `${formId}-step-0`, value: '' }] + ) + const [error, setError] = useState(null) + const [stepCounter, setStepCounter] = useState(feature.steps.length || 1) + + const updateFeature = useUpdateFeature(projectName) + + const handleAddStep = () => { + setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }]) + setStepCounter(stepCounter + 1) + } + + const handleRemoveStep = (id: string) => { + setSteps(steps.filter(step => step.id !== id)) + } + + const handleStepChange = (id: string, value: string) => { + setSteps(steps.map(step => + step.id === id ? { ...step, value } : step + )) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError(null) + + const filteredSteps = steps + .map(s => s.value.trim()) + .filter(s => s.length > 0) + + try { + await updateFeature.mutateAsync({ + featureId: feature.id, + update: { + category: category.trim(), + name: name.trim(), + description: description.trim(), + steps: filteredSteps, + priority: parseInt(priority, 10), + }, + }) + onSaved() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update feature') + } + } + + const isValid = category.trim() && name.trim() && description.trim() + + // Check if any changes were made + const currentSteps = steps.map(s => s.value.trim()).filter(s => s) + const hasChanges = + category.trim() !== feature.category || + name.trim() !== feature.name || + description.trim() !== feature.description || + parseInt(priority, 10) !== feature.priority || + JSON.stringify(currentSteps) !== JSON.stringify(feature.steps) + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ Edit Feature +

+ +
+ + {/* Form */} +
+ {/* Error Message */} + {error && ( +
+ + {error} + +
+ )} + + {/* Category & Priority Row */} +
+
+ + setCategory(e.target.value)} + placeholder="e.g., Authentication, UI, API" + className="neo-input" + required + /> +
+
+ + setPriority(e.target.value)} + min="1" + className="neo-input" + required + /> +
+
+ + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g., User login form" + className="neo-input" + required + /> +
+ + {/* Description */} +
+ +