diff --git a/backend/alembic/versions/4cdb5cba1aff_add_custom_steps_to_sessions.py b/backend/alembic/versions/4cdb5cba1aff_add_custom_steps_to_sessions.py new file mode 100644 index 00000000..aa71770c --- /dev/null +++ b/backend/alembic/versions/4cdb5cba1aff_add_custom_steps_to_sessions.py @@ -0,0 +1,31 @@ +"""add_custom_steps_to_sessions + +Revision ID: 4cdb5cba1aff +Revises: 008 +Create Date: 2026-02-03 19:12:42.551966 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +# revision identifiers, used by Alembic. +revision: str = '4cdb5cba1aff' +down_revision: Union[str, None] = '008' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add custom_steps JSONB column to sessions table + op.add_column('sessions', + sa.Column('custom_steps', JSONB, nullable=False, server_default='[]') + ) + + +def downgrade() -> None: + # Remove custom_steps column from sessions table + op.drop_column('sessions', 'custom_steps') diff --git a/backend/app/models/session.py b/backend/app/models/session.py index e04ce288..4d222519 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -30,6 +30,7 @@ class Session(Base): tree_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list) + custom_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list) started_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index c653144f..ab0033cf 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -24,6 +24,7 @@ class SessionCreate(BaseModel): class SessionUpdate(BaseModel): path_taken: Optional[list[str]] = None decisions: Optional[list[DecisionRecord]] = None + custom_steps: Optional[list[dict[str, Any]]] = None ticket_number: Optional[str] = Field(None, max_length=100) client_name: Optional[str] = Field(None, max_length=255) @@ -35,6 +36,7 @@ class SessionResponse(BaseModel): tree_snapshot: dict[str, Any] path_taken: list[str] decisions: list[dict[str, Any]] + custom_steps: list[dict[str, Any]] = Field(default_factory=list) started_at: datetime completed_at: Optional[datetime] = None ticket_number: Optional[str] = None diff --git a/frontend/src/components/step-library/CustomStepModal.tsx b/frontend/src/components/step-library/CustomStepModal.tsx new file mode 100644 index 00000000..eb4d57b6 --- /dev/null +++ b/frontend/src/components/step-library/CustomStepModal.tsx @@ -0,0 +1,148 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { stepsApi } from '@/api' +import { StepForm } from './StepForm' +import { StepLibraryBrowser } from './StepLibraryBrowser' +import type { Step, StepCreate } from '@/types/step' + +export interface CustomStepDraft { + title: string + step_type: 'decision' | 'action' | 'solution' + content: { + instructions: string + help_text?: string + commands?: Array<{ + label: string + command: string + command_type?: string + }> + } + category_id?: string + tags?: string[] +} + +interface CustomStepModalProps { + isOpen: boolean + onClose: () => void + onInsertStep: (step: Step | CustomStepDraft) => void +} + +type Tab = 'create' | 'browse' + +export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepModalProps) { + const [activeTab, setActiveTab] = useState('create') + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + if (!isOpen) return null + + const handleFormSubmit = async (data: StepCreate, saveToLibrary: boolean) => { + setIsSubmitting(true) + setError(null) + + try { + if (saveToLibrary) { + // Save to library first, then return the saved step + const savedStep = await stepsApi.create(data) + onInsertStep(savedStep) + } else { + // Return as draft (not saved to library) + const draft: CustomStepDraft = { + title: data.title, + step_type: data.step_type, + content: data.content, + category_id: data.category_id, + tags: data.tags + } + onInsertStep(draft) + } + } catch (err) { + console.error('Failed to create step:', err) + setError('Failed to create step. Please try again.') + setIsSubmitting(false) + } + } + + const handleBrowserInsert = (step: Step) => { + onInsertStep(step) + } + + return ( +
+
+ {/* Header */} +
+

Add Custom Step

+ +
+ + {/* Tabs */} +
+ + +
+ + {/* Error Display */} + {error && ( +
+ {error} +
+ )} + + {/* Tab Content */} +
+ {activeTab === 'create' ? ( +
+ +
+ ) : ( + + )} +
+ + {/* Loading Overlay */} + {isSubmitting && ( +
+
+
+

Creating step...

+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/step-library/StepForm.tsx b/frontend/src/components/step-library/StepForm.tsx new file mode 100644 index 00000000..1c01d76f --- /dev/null +++ b/frontend/src/components/step-library/StepForm.tsx @@ -0,0 +1,401 @@ +import { useState, useEffect } from 'react' +import { Plus, X, HelpCircle, Zap, CheckCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { stepCategoriesApi } from '@/api' +import type { StepCreate, StepCategory, StepCommand } from '@/types/step' + +interface StepFormProps { + onSubmit: (data: StepCreate, saveToLibrary: boolean) => void + onCancel: () => void + initialData?: Partial +} + +const stepTypeOptions = [ + { value: 'decision', label: 'Decision', icon: HelpCircle, description: 'Question with multiple options' }, + { value: 'action', label: 'Action', icon: Zap, description: 'Task to perform' }, + { value: 'solution', label: 'Solution', icon: CheckCircle, description: 'Resolution endpoint' } +] as const + +export function StepForm({ onSubmit, onCancel, initialData }: StepFormProps) { + // Form state + const [stepType, setStepType] = useState<'decision' | 'action' | 'solution'>( + initialData?.step_type || 'action' + ) + const [title, setTitle] = useState(initialData?.title || '') + const [instructions, setInstructions] = useState(initialData?.content?.instructions || '') + const [helpText, setHelpText] = useState(initialData?.content?.help_text || '') + const [commands, setCommands] = useState(initialData?.content?.commands || []) + const [categoryId, setCategoryId] = useState(initialData?.category_id || '') + const [tags, setTags] = useState(initialData?.tags || []) + const [tagInput, setTagInput] = useState('') + const [visibility, setVisibility] = useState<'private' | 'team' | 'public'>( + initialData?.visibility || 'private' + ) + const [saveToLibrary, setSaveToLibrary] = useState(true) + + // Categories + const [categories, setCategories] = useState([]) + + // Validation + const [errors, setErrors] = useState>({}) + + useEffect(() => { + const loadCategories = async () => { + try { + const data = await stepCategoriesApi.list() + setCategories(data.filter(c => c.is_active)) + } catch (err) { + console.error('Failed to load categories:', err) + } + } + loadCategories() + }, []) + + const addCommand = () => { + setCommands([...commands, { label: '', command: '', command_type: 'shell' }]) + } + + const removeCommand = (index: number) => { + setCommands(commands.filter((_, i) => i !== index)) + } + + const updateCommand = (index: number, field: keyof StepCommand, value: string) => { + const updated = [...commands] + updated[index] = { ...updated[index], [field]: value } + setCommands(updated) + } + + const addTag = () => { + const trimmed = tagInput.trim() + if (trimmed && !tags.includes(trimmed)) { + setTags([...tags, trimmed]) + setTagInput('') + } + } + + const removeTag = (tag: string) => { + setTags(tags.filter(t => t !== tag)) + } + + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + addTag() + } + } + + const validate = (): boolean => { + const newErrors: Record = {} + + if (!title.trim()) { + newErrors.title = 'Title is required' + } + + if (!instructions.trim()) { + newErrors.instructions = 'Instructions are required' + } + + // Validate commands + commands.forEach((cmd, i) => { + if (cmd.label && !cmd.command) { + newErrors[`command_${i}_command`] = 'Command is required if label is provided' + } + if (cmd.command && !cmd.label) { + newErrors[`command_${i}_label`] = 'Label is required if command is provided' + } + }) + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!validate()) { + return + } + + const data: StepCreate = { + title: title.trim(), + step_type: stepType, + content: { + instructions: instructions.trim(), + help_text: helpText.trim() || undefined, + commands: commands.filter(c => c.label && c.command).length > 0 + ? commands.filter(c => c.label && c.command) + : undefined + }, + visibility, + category_id: categoryId || undefined, + tags: tags.length > 0 ? tags : undefined + } + + onSubmit(data, saveToLibrary) + } + + return ( +
+ {/* Step Type */} +
+ +
+ {stepTypeOptions.map(option => { + const Icon = option.icon + return ( + + ) + })} +
+
+ + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Enter step title" + className={cn( + 'w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring', + errors.title ? 'border-destructive' : 'border-input' + )} + /> + {errors.title && ( +

{errors.title}

+ )} +
+ + {/* Instructions */} +
+ +