feat: implement QuestionBank CRUD with pagination and template query
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
|
||||
import React from 'react';
|
||||
import { KnowledgeGroup } from '../types';
|
||||
import { Check, ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
interface GroupSelectorProps {
|
||||
groups: KnowledgeGroup[];
|
||||
selectedGroups: string[];
|
||||
onSelectionChange: (groupIds: string[]) => void;
|
||||
showSelectAll?: boolean;
|
||||
placeholder?: string;
|
||||
minimal?: boolean;
|
||||
direction?: 'up' | 'bottom'; // Added direction prop
|
||||
}
|
||||
|
||||
export const GroupSelector: React.FC<GroupSelectorProps> = ({
|
||||
groups,
|
||||
selectedGroups,
|
||||
onSelectionChange,
|
||||
showSelectAll = true,
|
||||
placeholder = 'Select group scope',
|
||||
minimal = false,
|
||||
direction = 'bottom'
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [dropdownStyle, setDropdownStyle] = React.useState<React.CSSProperties>({});
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
const button = containerRef.current.querySelector('button') as HTMLElement;
|
||||
if (button) {
|
||||
const rect = button.getBoundingClientRect();
|
||||
|
||||
// Calculate style based on direction
|
||||
const style: React.CSSProperties = {
|
||||
left: rect.left,
|
||||
width: Math.max(rect.width, 240), // Min width for readability
|
||||
};
|
||||
|
||||
if (direction === 'up') {
|
||||
style.bottom = window.innerHeight - rect.top + 4;
|
||||
style.maxHeight = '320px'; // Increased height for search + list
|
||||
} else {
|
||||
style.top = rect.bottom + 4;
|
||||
style.maxHeight = '320px';
|
||||
}
|
||||
|
||||
setDropdownStyle(style);
|
||||
|
||||
// Auto-focus search input when opening
|
||||
setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
}
|
||||
} else {
|
||||
setSearchTerm(''); // Reset search on close
|
||||
}
|
||||
}, [isOpen, direction]);
|
||||
|
||||
const filteredGroups = groups.filter(g =>
|
||||
g.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const isAllSelected = selectedGroups.length === 0;
|
||||
|
||||
// Optimized display logic
|
||||
let selectedGroupNames = '';
|
||||
if (selectedGroups.length === 0) {
|
||||
selectedGroupNames = 'All groups';
|
||||
} else if (selectedGroups.length <= 2) {
|
||||
selectedGroupNames = selectedGroups.map(id => groups.find(g => g.id === id)?.name).filter(Boolean).join(', ');
|
||||
} else {
|
||||
selectedGroupNames = `Selected ${selectedGroups.length} groups`;
|
||||
}
|
||||
|
||||
const handleToggleGroup = (groupId: string) => {
|
||||
if (selectedGroups.includes(groupId)) {
|
||||
onSelectionChange(selectedGroups.filter(id => id !== groupId));
|
||||
} else {
|
||||
onSelectionChange([...selectedGroups, groupId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectionChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${minimal ? '' : 'w-full'} `} ref={containerRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items - center justify - between px - 3 py - 2 bg - white border border - gray - 300 rounded - md text - left focus: outline - none focus: ring - 2 focus: ring - blue - 500 ${minimal ? 'h-9 text-sm min-w-[120px]' : 'w-full'} `}
|
||||
title={selectedGroups.length > 2 ? selectedGroups.map(id => groups.find(g => g.id === id)?.name).join(', ') : undefined}
|
||||
>
|
||||
<span className={`truncate mr - 2 ${selectedGroups.length === 0 ? 'text-gray-500' : 'text-gray-900'} `} style={{ maxWidth: minimal ? '120px' : 'none' }}>
|
||||
{selectedGroupNames || placeholder}
|
||||
</span>
|
||||
<ChevronDown size={14} className={`text - gray - 400 text - xs transition - transform flex - shrink - 0 ${isOpen ? 'rotate-180' : ''} `} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
<div className="fixed bg-white border border-gray-300 rounded-lg shadow-xl z-[9999] flex flex-col animate-in fade-in zoom-in-95 duration-100"
|
||||
style={dropdownStyle}>
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="p-2 border-b border-gray-100 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search groups..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-gray-50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 p-0">
|
||||
{!searchTerm && showSelectAll && (
|
||||
<div
|
||||
onClick={handleSelectAll}
|
||||
className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 border - b border - gray - 100 ${isAllSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
|
||||
} `}
|
||||
>
|
||||
<div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center ${isAllSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
|
||||
} `}>
|
||||
{isAllSelected && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="font-medium text-sm">All groups</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="py-1">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = selectedGroups.includes(group.id);
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
onClick={() => handleToggleGroup(group.id)}
|
||||
className={`flex items - center px - 3 py - 2 cursor - pointer hover: bg - gray - 50 transition - colors ${isSelected ? 'bg-blue-50' : ''
|
||||
} `}
|
||||
>
|
||||
<div className={`w - 4 h - 4 mr - 3 border rounded flex items - center justify - center flex - shrink - 0 ${isSelected ? 'bg-blue-600 border-blue-600' : 'border-gray-300'
|
||||
} `}>
|
||||
{isSelected && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full mr-2 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text - sm truncate ${isSelected ? 'text-blue-700 font-medium' : 'text-gray-700'} `}>
|
||||
{group.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400">
|
||||
{group.fileCount} files
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="px-3 py-6 text-center text-gray-400 text-xs">
|
||||
{searchTerm ? 'No related groups found' : 'No groups'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user