Spaces:
Sleeping
Sleeping
| /** | |
| * Generated Test Cases Preview Modal | |
| * | |
| * Shows AI-generated test cases before saving. | |
| * User can review, edit, and selectively save test cases. | |
| */ | |
| import React, { useState } from "react"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogDescription, | |
| DialogFooter, | |
| } from "@/components/ui/dialog"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { Loader2, CheckCircle, XCircle, Save, Sparkles, Edit, ChevronDown, ChevronUp } from "lucide-react"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { FrontendTestCase } from "@/services/aiTestCaseApi"; | |
| import { tasksApi } from "@/services/tasksApi"; | |
| interface GeneratedTestCasesPreviewProps { | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| testCases: FrontendTestCase[]; | |
| featureId: number; | |
| onSaveComplete: () => void; | |
| } | |
| const GeneratedTestCasesPreview: React.FC<GeneratedTestCasesPreviewProps> = ({ | |
| open, | |
| onOpenChange, | |
| testCases, | |
| featureId, | |
| onSaveComplete, | |
| }) => { | |
| const [selectedCases, setSelectedCases] = useState<Set<number>>(new Set(testCases.map((_, i) => i))); | |
| const [savingStates, setSavingStates] = useState<Record<number, 'idle' | 'saving' | 'saved' | 'error'>>({}); | |
| const [isSavingAll, setIsSavingAll] = useState(false); | |
| const [expandedCases, setExpandedCases] = useState<Set<number>>(new Set([0])); // First case expanded by default | |
| const [editedCases, setEditedCases] = useState<Record<number, FrontendTestCase>>({}); | |
| // Toggle selection | |
| const toggleSelection = (index: number) => { | |
| const newSelected = new Set(selectedCases); | |
| if (newSelected.has(index)) { | |
| newSelected.delete(index); | |
| } else { | |
| newSelected.add(index); | |
| } | |
| setSelectedCases(newSelected); | |
| }; | |
| // Toggle expand/collapse | |
| const toggleExpand = (index: number) => { | |
| const newExpanded = new Set(expandedCases); | |
| if (newExpanded.has(index)) { | |
| newExpanded.delete(index); | |
| } else { | |
| newExpanded.add(index); | |
| } | |
| setExpandedCases(newExpanded); | |
| }; | |
| // Select/deselect all | |
| const toggleSelectAll = () => { | |
| if (selectedCases.size === testCases.length) { | |
| setSelectedCases(new Set()); | |
| } else { | |
| setSelectedCases(new Set(testCases.map((_, i) => i))); | |
| } | |
| }; | |
| // Update edited test case | |
| const updateTestCase = (index: number, field: keyof FrontendTestCase, value: string) => { | |
| setEditedCases(prev => ({ | |
| ...prev, | |
| [index]: { | |
| ...(editedCases[index] || testCases[index]), | |
| [field]: value, | |
| }, | |
| })); | |
| }; | |
| // Save single test case | |
| const saveTestCase = async (index: number) => { | |
| const testCase = editedCases[index] || testCases[index]; | |
| setSavingStates(prev => ({ ...prev, [index]: 'saving' })); | |
| try { | |
| const taskData = { | |
| type: 'Test Case', | |
| title: testCase.title, | |
| description: testCase.description, | |
| issuesId: featureId, | |
| projectId: testCase.projectId, | |
| status: testCase.status || 'Draft', | |
| priority: testCase.priority || 'Medium', | |
| severity: testCase.severity || 'Minor', | |
| environment: testCase.environment || 'Staging', | |
| stepsToReproduce: testCase.stepsToReproduce || '', | |
| inputs: testCase.inputs || '', | |
| expectedBehavior: testCase.expectedBehavior || '', | |
| actualBehavior: testCase.actualBehavior || '', | |
| rootCause: testCase.rootCause || '', | |
| resolution: testCase.resolution || '', | |
| version: testCase.version || '', | |
| url: testCase.url || '', | |
| }; | |
| await tasksApi.create(taskData); | |
| setSavingStates(prev => ({ ...prev, [index]: 'saved' })); | |
| toast.success(`Saved: ${testCase.title}`); | |
| } catch (error) { | |
| console.error('Failed to save test case:', error); | |
| setSavingStates(prev => ({ ...prev, [index]: 'error' })); | |
| toast.error(`Failed to save: ${testCase.title}`); | |
| } | |
| }; | |
| // Save all selected test cases | |
| const saveSelected = async () => { | |
| if (selectedCases.size === 0) { | |
| toast.warning('No test cases selected'); | |
| return; | |
| } | |
| setIsSavingAll(true); | |
| const indices = Array.from(selectedCases); | |
| for (const index of indices) { | |
| if (savingStates[index] !== 'saved') { | |
| await saveTestCase(index); | |
| } | |
| } | |
| setIsSavingAll(false); | |
| onSaveComplete(); | |
| // Check if all saved successfully | |
| const savedCount = indices.filter(i => savingStates[i] === 'saved').length; | |
| if (savedCount === indices.length) { | |
| toast.success(`All ${savedCount} test cases saved successfully!`); | |
| onOpenChange(false); | |
| } | |
| }; | |
| // Get priority badge color | |
| const getPriorityColor = (priority: string) => { | |
| switch (priority?.toLowerCase()) { | |
| case 'critical': return 'bg-red-100 text-red-800 border-red-300'; | |
| case 'high': return 'bg-orange-100 text-orange-800 border-orange-300'; | |
| case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-300'; | |
| case 'low': return 'bg-green-100 text-green-800 border-green-300'; | |
| default: return 'bg-gray-100 text-gray-800 border-gray-300'; | |
| } | |
| }; | |
| // Get severity badge color | |
| const getSeverityColor = (severity: string) => { | |
| switch (severity?.toLowerCase()) { | |
| case 'blocker': return 'bg-red-100 text-red-800 border-red-300'; | |
| case 'critical': return 'bg-red-100 text-red-800 border-red-300'; | |
| case 'major': return 'bg-orange-100 text-orange-800 border-orange-300'; | |
| case 'minor': return 'bg-yellow-100 text-yellow-800 border-yellow-300'; | |
| case 'trivial': return 'bg-green-100 text-green-800 border-green-300'; | |
| default: return 'bg-gray-100 text-gray-800 border-gray-300'; | |
| } | |
| }; | |
| // Reset state when dialog opens | |
| React.useEffect(() => { | |
| if (open) { | |
| setSelectedCases(new Set(testCases.map((_, i) => i))); | |
| setSavingStates({}); | |
| setExpandedCases(new Set([0])); | |
| setEditedCases({}); | |
| } | |
| }, [open, testCases]); | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center gap-2"> | |
| <Sparkles className="h-5 w-5 text-purple-500" /> | |
| Generated Test Cases Preview | |
| </DialogTitle> | |
| <DialogDescription> | |
| Review the AI-generated test cases below. Select which ones to save to the database. | |
| You can edit any field before saving. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="flex items-center justify-between py-2 border-b"> | |
| <div className="flex items-center gap-2"> | |
| <Checkbox | |
| id="select-all" | |
| checked={selectedCases.size === testCases.length} | |
| onCheckedChange={toggleSelectAll} | |
| /> | |
| <label htmlFor="select-all" className="text-sm font-medium cursor-pointer"> | |
| Select All ({selectedCases.size}/{testCases.length} selected) | |
| </label> | |
| </div> | |
| <div className="text-sm text-muted-foreground"> | |
| Feature ID: {featureId} | |
| </div> | |
| </div> | |
| <div className="flex-1 overflow-y-auto space-y-3 py-4 pr-2"> | |
| {testCases.map((tc, index) => { | |
| const currentCase = editedCases[index] || tc; | |
| const isExpanded = expandedCases.has(index); | |
| const isSelected = selectedCases.has(index); | |
| const saveState = savingStates[index] || 'idle'; | |
| return ( | |
| <div | |
| key={index} | |
| className={`border rounded-lg transition-all ${ | |
| isSelected ? 'border-purple-300 bg-purple-50/30' : 'border-gray-200' | |
| }`} | |
| > | |
| {/* Header */} | |
| <div className="flex items-start gap-3 p-3"> | |
| <Checkbox | |
| checked={isSelected} | |
| onCheckedChange={() => toggleSelection(index)} | |
| className="mt-1" | |
| /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1"> | |
| <input | |
| type="text" | |
| value={currentCase.title} | |
| onChange={(e) => updateTestCase(index, 'title', e.target.value)} | |
| className="font-medium text-sm w-full bg-transparent border-b border-transparent hover:border-gray-300 focus:border-purple-500 focus:outline-none px-0.5" | |
| /> | |
| <div className="flex flex-wrap items-center gap-1.5 mt-1.5"> | |
| <Badge variant="outline" className={getPriorityColor(currentCase.priority)}> | |
| {currentCase.priority} | |
| </Badge> | |
| <Badge variant="outline" className={getSeverityColor(currentCase.severity)}> | |
| {currentCase.severity} | |
| </Badge> | |
| <Badge variant="outline" className="bg-blue-50 text-blue-800 border-blue-200"> | |
| {currentCase.environment} | |
| </Badge> | |
| {saveState === 'saved' && ( | |
| <Badge className="bg-green-500 text-white"> | |
| <CheckCircle className="h-3 w-3 mr-1" /> | |
| Saved | |
| </Badge> | |
| )} | |
| {saveState === 'error' && ( | |
| <Badge className="bg-red-500 text-white"> | |
| <XCircle className="h-3 w-3 mr-1" /> | |
| Error | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => saveTestCase(index)} | |
| disabled={saveState === 'saving' || saveState === 'saved'} | |
| className="h-8" | |
| > | |
| {saveState === 'saving' ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Save className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => toggleExpand(index)} | |
| className="h-8" | |
| > | |
| {isExpanded ? ( | |
| <ChevronUp className="h-4 w-4" /> | |
| ) : ( | |
| <ChevronDown className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Expanded Content */} | |
| {isExpanded && ( | |
| <div className="px-3 pb-3 pt-0 space-y-3 border-t"> | |
| {/* Description */} | |
| <div className="pt-3"> | |
| <label className="text-xs font-medium text-muted-foreground">Description</label> | |
| <textarea | |
| value={currentCase.description} | |
| onChange={(e) => updateTestCase(index, 'description', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[60px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| /> | |
| </div> | |
| {/* Steps to Reproduce */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Steps to Reproduce</label> | |
| <textarea | |
| value={currentCase.stepsToReproduce || ''} | |
| onChange={(e) => updateTestCase(index, 'stepsToReproduce', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[80px] font-mono focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="1. First step 2. Second step 3. Third step" | |
| /> | |
| </div> | |
| {/* Test Data / Inputs */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Test Data / Inputs</label> | |
| <textarea | |
| value={currentCase.inputs || ''} | |
| onChange={(e) => updateTestCase(index, 'inputs', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| /> | |
| </div> | |
| {/* Expected Behavior */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Expected Behavior</label> | |
| <textarea | |
| value={currentCase.expectedBehavior || ''} | |
| onChange={(e) => updateTestCase(index, 'expectedBehavior', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| /> | |
| </div> | |
| {/* Two column layout for smaller fields */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Priority</label> | |
| <select | |
| value={currentCase.priority || 'Medium'} | |
| onChange={(e) => updateTestCase(index, 'priority', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Critical">Critical</option> | |
| <option value="High">High</option> | |
| <option value="Medium">Medium</option> | |
| <option value="Low">Low</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Severity</label> | |
| <select | |
| value={currentCase.severity || 'Minor'} | |
| onChange={(e) => updateTestCase(index, 'severity', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Blocker">Blocker</option> | |
| <option value="Critical">Critical</option> | |
| <option value="Major">Major</option> | |
| <option value="Minor">Minor</option> | |
| <option value="Trivial">Trivial</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Environment</label> | |
| <select | |
| value={currentCase.environment || 'Staging'} | |
| onChange={(e) => updateTestCase(index, 'environment', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Development">Development</option> | |
| <option value="Testing">Testing</option> | |
| <option value="Staging">Staging</option> | |
| <option value="Production">Production</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Version</label> | |
| <input | |
| type="text" | |
| value={currentCase.version || ''} | |
| onChange={(e) => updateTestCase(index, 'version', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="e.g., 1.2.3" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <DialogFooter className="border-t pt-4"> | |
| <div className="flex items-center justify-between w-full"> | |
| <div className="text-sm text-muted-foreground"> | |
| {selectedCases.size} test case(s) selected | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button variant="outline" onClick={() => onOpenChange(false)}> | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={saveSelected} | |
| disabled={selectedCases.size === 0 || isSavingAll} | |
| className="bg-purple-600 hover:bg-purple-700" | |
| > | |
| {isSavingAll ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin mr-2" /> | |
| Saving... | |
| </> | |
| ) : ( | |
| <> | |
| <Save className="h-4 w-4 mr-2" /> | |
| Save Selected ({selectedCases.size}) | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| }; | |
| export default GeneratedTestCasesPreview; | |