215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
|
|
import { useState, useEffect } from 'react'
|
||
|
|
import { useQuery } from '@tanstack/react-query'
|
||
|
|
import { testsApi } from '@/api/tests'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import {
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableHead,
|
||
|
|
TableHeader,
|
||
|
|
TableRow,
|
||
|
|
} from '@/components/ui/table'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Search, Check } from 'lucide-react'
|
||
|
|
|
||
|
|
interface PackTestsManagerProps {
|
||
|
|
currentTestIds: string[]
|
||
|
|
onTestsChange: (addIds: string[], removeIds: string[]) => void
|
||
|
|
disabled?: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PackTestsManager({
|
||
|
|
currentTestIds,
|
||
|
|
onTestsChange,
|
||
|
|
disabled = false,
|
||
|
|
}: PackTestsManagerProps) {
|
||
|
|
const [search, setSearch] = useState('')
|
||
|
|
const [selectedTests, setSelectedTests] = useState<Set<string>>(new Set())
|
||
|
|
const [removedTests, setRemovedTests] = useState<Set<string>>(new Set())
|
||
|
|
|
||
|
|
// Load all tests with search
|
||
|
|
const { data: testsData, isLoading } = useQuery({
|
||
|
|
queryKey: ['tests', 1, 100, search], // Limit to 100 (backend max)
|
||
|
|
queryFn: () => testsApi.getTests({ page: 1, limit: 100, search }),
|
||
|
|
enabled: !disabled,
|
||
|
|
})
|
||
|
|
|
||
|
|
// Initialize selected tests from currentTestIds when it changes
|
||
|
|
useEffect(() => {
|
||
|
|
const initialSelected = new Set<string>(
|
||
|
|
currentTestIds.filter((id) => !removedTests.has(id.toString()))
|
||
|
|
)
|
||
|
|
setSelectedTests(initialSelected)
|
||
|
|
// Reset removed tests when currentTestIds changes (e.g., when opening dialog)
|
||
|
|
setRemovedTests(new Set())
|
||
|
|
}, [currentTestIds.join(',')]) // Use join to detect array changes
|
||
|
|
|
||
|
|
// Calculate which tests to add/remove when selection changes
|
||
|
|
useEffect(() => {
|
||
|
|
const currentlySelected = Array.from(selectedTests)
|
||
|
|
const removed = Array.from(removedTests)
|
||
|
|
|
||
|
|
// Tests to add: selected but not in currentTestIds and not removed
|
||
|
|
const toAdd = currentlySelected.filter(
|
||
|
|
(id) => !currentTestIds.includes(id) && !removed.includes(id)
|
||
|
|
)
|
||
|
|
// Tests to remove: in removedTests
|
||
|
|
const toRemove = removed.filter((id) => currentTestIds.includes(id))
|
||
|
|
|
||
|
|
if (toAdd.length > 0 || toRemove.length > 0) {
|
||
|
|
onTestsChange(toAdd, toRemove)
|
||
|
|
} else if (selectedTests.size > 0 || removedTests.size > 0) {
|
||
|
|
// Also notify if selection was cleared
|
||
|
|
onTestsChange([], [])
|
||
|
|
}
|
||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
|
|
}, [selectedTests.size, removedTests.size, currentTestIds.join(',')])
|
||
|
|
|
||
|
|
const handleToggleTest = (testId: string) => {
|
||
|
|
if (disabled || !testId) return
|
||
|
|
|
||
|
|
const testIdStr = String(testId)
|
||
|
|
const isCurrentlySelected = selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||
|
|
const isInCurrentPack = currentTestIds.includes(testIdStr)
|
||
|
|
|
||
|
|
if (isCurrentlySelected) {
|
||
|
|
// Deselect test
|
||
|
|
const newSelected = new Set(selectedTests)
|
||
|
|
newSelected.delete(testIdStr)
|
||
|
|
setSelectedTests(newSelected)
|
||
|
|
|
||
|
|
// If it was in current pack, mark as removed
|
||
|
|
if (isInCurrentPack) {
|
||
|
|
setRemovedTests((prev) => new Set([...prev, testIdStr]))
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Select test
|
||
|
|
const newSelected = new Set(selectedTests)
|
||
|
|
newSelected.add(testIdStr)
|
||
|
|
setSelectedTests(newSelected)
|
||
|
|
|
||
|
|
// If it was marked as removed, unmark it
|
||
|
|
if (removedTests.has(testIdStr)) {
|
||
|
|
setRemovedTests((prev) => {
|
||
|
|
const newRemoved = new Set(prev)
|
||
|
|
newRemoved.delete(testIdStr)
|
||
|
|
return newRemoved
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const isTestSelected = (testId: string | number) => {
|
||
|
|
const testIdStr = String(testId)
|
||
|
|
return selectedTests.has(testIdStr) && !removedTests.has(testIdStr)
|
||
|
|
}
|
||
|
|
|
||
|
|
const isTestInCurrentPack = (testId: string | number) => {
|
||
|
|
return currentTestIds.includes(String(testId))
|
||
|
|
}
|
||
|
|
|
||
|
|
const allTests = testsData?.items || []
|
||
|
|
const filteredTests = search
|
||
|
|
? allTests.filter(
|
||
|
|
(test) =>
|
||
|
|
test.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||
|
|
test.id?.toLowerCase().includes(search.toLowerCase())
|
||
|
|
)
|
||
|
|
: allTests
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div className="space-y-2">
|
||
|
|
<Label>Manage Tests in Pack</Label>
|
||
|
|
<div className="flex items-center space-x-2">
|
||
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
placeholder="Search tests by name or ID..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
className="max-w-sm"
|
||
|
|
disabled={disabled}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
Selected tests: {selectedTests.size - removedTests.size} / {allTests.length}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isLoading ? (
|
||
|
|
<div className="text-center py-4">Loading tests...</div>
|
||
|
|
) : (
|
||
|
|
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-12"></TableHead>
|
||
|
|
<TableHead>ID</TableHead>
|
||
|
|
<TableHead>Name</TableHead>
|
||
|
|
<TableHead>Questions</TableHead>
|
||
|
|
<TableHead>Version</TableHead>
|
||
|
|
<TableHead>Status</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{filteredTests.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||
|
|
No tests found
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
filteredTests
|
||
|
|
.filter((test) => test.id) // Only show tests with IDs
|
||
|
|
.map((test) => {
|
||
|
|
const testIdStr = String(test.id!)
|
||
|
|
const selected = isTestSelected(testIdStr)
|
||
|
|
const inCurrentPack = isTestInCurrentPack(testIdStr)
|
||
|
|
const newlyRemoved = removedTests.has(testIdStr)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<TableRow
|
||
|
|
key={test.id}
|
||
|
|
className={selected ? 'bg-muted/50' : ''}
|
||
|
|
onClick={() => handleToggleTest(testIdStr)}
|
||
|
|
>
|
||
|
|
<TableCell>
|
||
|
|
{selected ? (
|
||
|
|
<Check className="h-4 w-4 text-primary" />
|
||
|
|
) : (
|
||
|
|
<div className="h-4 w-4 border rounded" />
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="font-mono text-sm">{test.id || '-'}</TableCell>
|
||
|
|
<TableCell className="font-medium">{test.name}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{typeof test.questions === 'number'
|
||
|
|
? test.questions
|
||
|
|
: test.questions?.length || 0}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>{test.version || 'N/A'}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
{newlyRemoved ? (
|
||
|
|
<Badge variant="destructive">Removed</Badge>
|
||
|
|
) : selected && inCurrentPack ? (
|
||
|
|
<Badge variant="default">In Pack</Badge>
|
||
|
|
) : selected ? (
|
||
|
|
<Badge variant="secondary">Selected</Badge>
|
||
|
|
) : inCurrentPack ? (
|
||
|
|
<Badge variant="outline">In Pack</Badge>
|
||
|
|
) : null}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
)
|
||
|
|
})
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|